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.37.43-T4 — PG-unreserved keywords that are legal identifiers
74/// per `pg_get_keywords()`. SPG tokenizes these as named variants
75/// so the parser can dispatch on them in their owning contexts
76/// (`RELEASE SAVEPOINT`, `SHOW name`, `BEGIN`/`COMMIT`/`ROLLBACK`,
77/// `CREATE INDEX`, etc.), but they MUST stay usable as table /
78/// column / alias names — that's the PG contract for unreserved
79/// keywords (see PG docs Appendix C.1).
80///
81/// Before this generalisation, sentori migration 0001_init.sql
82/// `release TEXT NOT NULL` blew up the parser with "expected
83/// identifier, got Release", and the same gap stalked every
84/// SPG drop-in user whose schema had a column / alias named
85/// `release` / `index` / `tables` / `show` / `savepoint` /
86/// `begin` / `commit` / `rollback` / `drop` / `insert` / `values`
87/// / `limit` / `partition`. PG accepts all of them as identifiers
88/// when unquoted, so SPG must too.
89///
90/// Returns the canonical lowercase identifier text when the token
91/// belongs to PG's unreserved class, `None` otherwise. Used by
92/// `expect_ident_like` (column / table / alias names) so the
93/// generalisation applies everywhere an identifier may appear,
94/// not just in the contexts these tokens were introduced for.
95fn unreserved_keyword_text(tok: &Token) -> Option<String> {
96    let s = match tok {
97        // PG keyword class: unreserved or col_name.
98        Token::Release => "release",
99        Token::Savepoint => "savepoint",
100        Token::Show => "show",
101        Token::Index => "index",
102        Token::Begin => "begin",
103        Token::Commit => "commit",
104        Token::Rollback => "rollback",
105        Token::Drop => "drop",
106        Token::Insert => "insert",
107        Token::Values => "values",
108        Token::Limit => "limit",
109        Token::Partition => "partition",
110        Token::Tables => "tables",
111        Token::Connection => "connection",
112        Token::Publication => "publication",
113        Token::Subscription => "subscription",
114        Token::Interval => "interval",
115        // `extract` is non-reserved in PG too (it's a function the
116        // parser dispatches via context — outside that context it's
117        // a plain identifier).
118        Token::Extract => "extract",
119        Token::Offset => "offset",
120        // `to` is reserved in PG (used in many "AS … TO …" forms), so
121        // it is NOT relaxed here. Same for `from`, `where`, `as`,
122        // `select`, `not`, `and`, `or`, `null`, `true`, `false`,
123        // `create`, `table`, `into`, `on`, `order`, `by`, `having`,
124        // `group`, `distinct`, `union`, `all`, `join`, `inner`,
125        // `left`, `cross`, `outer`, `default`, `is`, `between`,
126        // `in`, `like`, `for`, `except`, `desc`, `asc`, `partition`
127        // (partial — keep partition as unreserved per modern PG).
128        _ => return None,
129    };
130    Some(s.to_string())
131}
132
133/// v7.9.22 — recognise pgvector / SPG vector-index opclass names
134/// in CREATE INDEX. SPG's HNSW already routes by query operator;
135/// the opclass is accepted for `pg_dump` compatibility (mailrs
136/// migration follow-up G5).
137/// v7.13.0 — extended to recognise PG built-in / pg_trgm opclasses
138/// (mailrs round-5 G5). These are tokens-only acceptance — SPG
139/// doesn't change index behaviour based on them.
140fn is_vector_opclass_name(name: &str) -> bool {
141    let lc = name.to_ascii_lowercase();
142    matches!(
143        lc.as_str(),
144        "vector_cosine_ops"
145            | "vector_l2_ops"
146            | "vector_ip_ops"
147            | "halfvec_cosine_ops"
148            | "halfvec_l2_ops"
149            | "halfvec_ip_ops"
150            | "sq8_cosine_ops"
151            | "sq8_l2_ops"
152            | "sq8_ip_ops"
153            // pg_trgm — trigram operator class. SPG's GIN index
154            // already uses tsvector tokens; trigram-style LIKE
155            // pattern matching still routes through a sequential
156            // scan, but the opclass name is accepted so PG schemas
157            // load.
158            | "gin_trgm_ops"
159            | "gist_trgm_ops"
160            // PG built-in btree opclasses occasionally appear in
161            // pg_dump output for column types with multiple
162            // sort orders (text_pattern_ops, varchar_pattern_ops,
163            // bpchar_pattern_ops).
164            | "text_pattern_ops"
165            | "varchar_pattern_ops"
166            | "bpchar_pattern_ops"
167            | "int4_ops"
168            | "int8_ops"
169            | "text_ops"
170    )
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct ParseError {
175    pub message: String,
176    /// Index into the token stream where parsing tripped. Not a byte offset.
177    pub token_pos: usize,
178}
179
180impl fmt::Display for ParseError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(
183            f,
184            "parse error at token #{}: {}",
185            self.token_pos, self.message
186        )
187    }
188}
189
190impl From<LexError> for ParseError {
191    fn from(e: LexError) -> Self {
192        Self {
193            message: format!("lex: {e}"),
194            token_pos: 0,
195        }
196    }
197}
198
199/// v7.9.30 — parse a single expression (no trailing junk). Used by
200/// the engine to re-hydrate stored partial-index / unique-index
201/// predicates from their canonical Display form. The same Pratt
202/// parser the statement path uses; this entry point just skips the
203/// statement dispatch.
204pub fn parse_expression(input: &str) -> Result<Expr, ParseError> {
205    let tokens = lexer::tokenize(input)?;
206    let mut p = Parser::new(tokens);
207    let expr = p.parse_expr(0)?;
208    p.expect_eof()?;
209    Ok(expr)
210}
211
212/// Parse exactly one statement, swallow an optional trailing `;`, and require
213/// the token stream to end there. PG string semantics.
214pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
215    parse_statement_with(input, false)
216}
217
218/// v7.22 (round-13 T3) — dialect-aware entry: `backslash_escapes`
219/// selects MySQL-style string lexing (see `lexer::tokenize_with`).
220/// The engine threads its session flag through here.
221pub fn parse_statement_with(input: &str, backslash_escapes: bool) -> Result<Statement, ParseError> {
222    let tokens = lexer::tokenize_with(input, backslash_escapes)?;
223    let mut p = Parser::new(tokens);
224    let stmt = p.parse_one_statement()?;
225    if matches!(p.peek(), Token::Semicolon) {
226        p.advance();
227    }
228    p.expect_eof()?;
229    Ok(stmt)
230}
231
232struct Parser {
233    tokens: Vec<Token>,
234    pos: usize,
235    /// v7.30.2 (mailrs round-25 ask 2) — live nesting depth of the
236    /// mutually recursive expr/select parsers. Bounded so a deeply
237    /// nested input returns a parse error instead of overflowing
238    /// the stack (embed hosts die on overflow — it is an abort,
239    /// not a catchable error).
240    nest_depth: usize,
241}
242
243/// Max expr/select parser nesting (parens, subqueries, CASE, …).
244/// Real SQL nests a few dozen levels at the extreme. Each nesting
245/// level costs a parse_expr→parse_unary→parse_atom frame chain —
246/// over 10 KiB in debug builds (parse_atom is a giant match) — so
247/// 64 is the highest budget that stays comfortably inside a 2 MiB
248/// worker stack in BOTH debug and release builds.
249const MAX_NEST_DEPTH: usize = 64;
250
251/// Max consecutive binary operators at ONE precedence level
252/// (`a OR b OR c …`, `1+1+1…`). The chain builds iteratively at
253/// parse time but evaluates and drops recursively — depth beyond
254/// this overflows 2 MiB worker stacks (debug eval frames run
255/// multiple KiB). `IN (…)` lists are flat and unaffected.
256const MAX_BINARY_CHAIN: usize = 256;
257
258/// v7.22 (round-13 gap 5) — the kind keyword after `CONSTRAINT
259/// <name>` in a CREATE TABLE column list. FOREIGN KEY is not here:
260/// it keeps its dedicated path (`parse_table_level_fk`).
261enum NamedTableConstraintKind {
262    Check,
263    Unique,
264    PrimaryKey,
265}
266
267impl Parser {
268    fn new(tokens: Vec<Token>) -> Self {
269        Self {
270            tokens,
271            pos: 0,
272            nest_depth: 0,
273        }
274    }
275
276    /// v7.30.2 (mailrs round-25 ask 2) — bump the expr/select
277    /// nesting depth, erroring out cleanly past the budget.
278    fn enter_nested(&mut self) -> Result<(), ParseError> {
279        self.nest_depth += 1;
280        if self.nest_depth > MAX_NEST_DEPTH {
281            self.nest_depth -= 1;
282            return Err(self.err(alloc::format!(
283                "statement nests deeper than {MAX_NEST_DEPTH} levels"
284            )));
285        }
286        Ok(())
287    }
288
289    fn peek(&self) -> &Token {
290        // tokens always ends with Eof; pos is clamped in advance().
291        &self.tokens[self.pos]
292    }
293
294    fn advance(&mut self) -> Token {
295        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
296        if self.pos + 1 < self.tokens.len() {
297            self.pos += 1;
298        }
299        t
300    }
301
302    fn err(&self, message: String) -> ParseError {
303        ParseError {
304            message,
305            token_pos: self.pos,
306        }
307    }
308
309    fn expect_eof(&self) -> Result<(), ParseError> {
310        if matches!(self.peek(), Token::Eof) {
311            Ok(())
312        } else {
313            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
314        }
315    }
316
317    /// v7.14.0 — swallow every token up to (but not including) the
318    /// next semicolon / EOF. Used by the dump-noise dispatcher
319    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
320    /// etc. without modeling each grammar.
321    fn consume_until_statement_boundary(&mut self) {
322        loop {
323            match self.peek() {
324                Token::Semicolon | Token::Eof => return,
325                _ => self.advance(),
326            };
327        }
328    }
329
330    /// v7.22 (round-13 T2) — consume to the statement boundary like
331    /// `consume_until_statement_boundary`, but pick out the sequence
332    /// name on the way: either `SEQUENCE NAME <ident>` (identity
333    /// columns) or the first string literal (`nextval('<seq>')`).
334    /// Schema qualifiers and `::regclass` casts are stripped.
335    fn scan_sequence_name_until_boundary(&mut self) -> Option<String> {
336        let mut seq: Option<String> = None;
337        let mut after_sequence_kw = false;
338        let mut after_name_kw = false;
339        loop {
340            match self.peek().clone() {
341                Token::Semicolon | Token::Eof => break,
342                Token::Ident(s) | Token::QuotedIdent(s) => {
343                    if after_name_kw && seq.is_none() {
344                        self.advance();
345                        let mut name = s;
346                        // `SEQUENCE NAME public.groups_id_seq` — keep
347                        // the bare name, drop qualifiers.
348                        while matches!(self.peek(), Token::Dot) {
349                            self.advance();
350                            if let Token::Ident(n) | Token::QuotedIdent(n) = self.advance() {
351                                name = n;
352                            }
353                        }
354                        seq = Some(name);
355                        after_name_kw = false;
356                        continue;
357                    }
358                    if after_sequence_kw && s.eq_ignore_ascii_case("name") {
359                        after_name_kw = true;
360                        after_sequence_kw = false;
361                    } else {
362                        after_sequence_kw = s.eq_ignore_ascii_case("sequence");
363                    }
364                    self.advance();
365                }
366                Token::String(s) => {
367                    if seq.is_none() {
368                        // `nextval('public.groups_id_seq'::regclass)`
369                        let bare = s
370                            .rsplit_once('.')
371                            .map_or_else(|| s.clone(), |(_, b)| b.to_string());
372                        seq = Some(bare);
373                    }
374                    self.advance();
375                }
376                _ => {
377                    after_sequence_kw = false;
378                    after_name_kw = false;
379                    self.advance();
380                }
381            }
382        }
383        seq
384    }
385
386    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
387        let first = match self.advance() {
388            Token::Ident(s) | Token::QuotedIdent(s) => s,
389            // v7.37.43-T4 — PG-unreserved keywords are legal identifiers
390            // per PG's `pg_get_keywords()` classification. SPG tokenizes
391            // these as named variants for parsing leverage in the
392            // contexts that own them (`RELEASE SAVEPOINT`, `SHOW name`,
393            // `BEGIN`, etc.), but they MUST still be usable as table /
394            // column / alias names in DDL+DML. Sentori migrations like
395            // 0001_init.sql ship `release TEXT NOT NULL` in the events
396            // table — the `events.release` column carries the release
397            // identifier string. Pre-T4 this triggered "expected
398            // identifier, got Release" and blocked every drop-in user
399            // whose schema had a column / alias with one of these names.
400            other if unreserved_keyword_text(&other).is_some() => {
401                unreserved_keyword_text(&other).unwrap()
402            }
403            other => {
404                return Err(ParseError {
405                    message: format!("expected identifier, got {other:?}"),
406                    token_pos: self.pos.saturating_sub(1),
407                });
408            }
409        };
410        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
411        // qualify every name with `public.` (and pg_catalog.* for
412        // functions); SPG is single-schema so we discard the
413        // prefix and return only the trailing ident. Same shape
414        // also handles MySQL `db.tbl` cross-database refs (SPG
415        // ignores the db part).
416        if matches!(self.peek(), Token::Dot) {
417            self.advance();
418            match self.advance() {
419                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
420                other if unreserved_keyword_text(&other).is_some() => {
421                    return Ok(unreserved_keyword_text(&other).unwrap());
422                }
423                other => {
424                    return Err(ParseError {
425                        message: format!("expected identifier after '{first}.', got {other:?}"),
426                        token_pos: self.pos.saturating_sub(1),
427                    });
428                }
429            }
430        }
431        Ok(first)
432    }
433
434    #[allow(clippy::too_many_lines)]
435    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
436        // v7.14.0 — empty / comment-only / semicolon-only input
437        // (after the lexer strips line + block + MySQL
438        // conditional comments) lands as Statement::Empty.
439        // pg_dump and mysqldump emit several wrappers that
440        // collapse to nothing after stripping (`/*!40101 SET …
441        // */;`, blank lines between statements); the engine
442        // returns CommandOk no-op so the dump loads cleanly.
443        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
444            return Ok(Statement::Empty);
445        }
446        // v7.14.0 — pg_dump / mysqldump "noise" statements:
447        // catalog / metadata DDL that has no behavioural effect
448        // on SPG's single-schema, single-database, single-user
449        // model. Consume the whole statement up to the next
450        // semicolon / EOF and return Empty. This is broader than
451        // the per-keyword DROP / SET / COMMENT arms but lets the
452        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
453        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
454        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
455        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
456            let lc = s.to_ascii_lowercase();
457            if is_dump_noise_statement(&lc) {
458                self.consume_until_statement_boundary();
459                return Ok(Statement::Empty);
460            }
461        }
462        match self.peek() {
463            Token::Select => self.parse_select_stmt(),
464            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
465            // body is a dollar-quoted plpgsql block (lexer already
466            // collapsed `$$…$$` into a single Token::String).
467            // v7.16.2 — mailrs round-10 A.2: parse the body as a
468            // real PlPgSqlBlock so the engine can EXECUTE it at
469            // top level instead of silently swallowing. Pre-
470            // v7.16.2 the parser threw the body away and the
471            // engine returned CommandOk for the entire DO; that
472            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
473            // $$` into a SEV-1 silent no-op (the IF + the rename
474            // were both invisible — mailrs's migrate-042 didn't
475            // actually run). Now the body parses + executes;
476            // EmbeddedSql inside the block runs immediately
477            // against the engine (not deferred — we're at top
478            // level, not inside a trigger row-write loop).
479            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
480                self.advance();
481                let body_text = match self.advance() {
482                    Token::String(s) => s,
483                    other => {
484                        return Err(self.err(alloc::format!(
485                            "expected dollar-quoted body after DO, got {other:?}"
486                        )));
487                    }
488                };
489                // Optional `LANGUAGE <name>` trailer (idents only).
490                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
491                    self.advance();
492                    let _ = self.expect_ident_like()?;
493                }
494                // Parse the body — same shape CREATE FUNCTION
495                // uses for trigger function bodies. If the body
496                // doesn't parse cleanly we surface the error
497                // (better than silent no-op).
498                let block = parse_plpgsql_body(&body_text)?;
499                Ok(Statement::DoBlock(block))
500            }
501            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
502            // WITH isn't a reserved token in our lexer — comes through
503            // as `Token::Ident("with")` (case-insensitive).
504            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
505                self.advance();
506                self.parse_with_cte_then_select()
507            }
508            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
509            // an identifier — not a reserved keyword.
510            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
511                self.advance();
512                let mut analyze = false;
513                let mut suggest = false;
514                let mut costs_off = false;
515                // v6.8.3 + v7.37.7 — `EXPLAIN (option [, option…])`
516                // syntax accepts SUGGEST + COSTS ON|OFF. Multiple
517                // options are comma-separated. Booleans default to ON
518                // when the value token is omitted (matches PG).
519                if matches!(self.peek(), Token::LParen) {
520                    self.advance();
521                    loop {
522                        let opt = match self.peek().clone() {
523                            Token::Ident(s) | Token::QuotedIdent(s) => s,
524                            other => {
525                                return Err(self.err(format!(
526                                    "expected option keyword inside EXPLAIN (…), got {other:?}"
527                                )));
528                            }
529                        };
530                        self.advance();
531                        if opt.eq_ignore_ascii_case("suggest") {
532                            suggest = true;
533                            // SUGGEST takes no explicit value today.
534                        } else if opt.eq_ignore_ascii_case("costs") {
535                            // PG syntax: `COSTS [ON | OFF]`. Default
536                            // when value omitted is ON, so plain
537                            // `COSTS` is a no-op. `COSTS OFF` flips.
538                            // `ON` lexes to `Token::On` (reserved
539                            // keyword in JOIN ... ON contexts); accept
540                            // it alongside the bare Ident form so the
541                            // grammar matches PG verbatim.
542                            let value = match self.peek().clone() {
543                                Token::On => {
544                                    self.advance();
545                                    true
546                                }
547                                Token::Ident(v) | Token::QuotedIdent(v)
548                                    if v.eq_ignore_ascii_case("off") =>
549                                {
550                                    self.advance();
551                                    false
552                                }
553                                Token::Ident(v) | Token::QuotedIdent(v)
554                                    if v.eq_ignore_ascii_case("true") =>
555                                {
556                                    self.advance();
557                                    true
558                                }
559                                _ => true,
560                            };
561                            costs_off = !value;
562                        } else {
563                            return Err(self.err(format!(
564                                "unknown EXPLAIN option {opt:?}; v7.37.7 supports SUGGEST, COSTS"
565                            )));
566                        }
567                        if matches!(self.peek(), Token::Comma) {
568                            self.advance();
569                            continue;
570                        }
571                        break;
572                    }
573                    if !matches!(self.peek(), Token::RParen) {
574                        return Err(self.err(format!(
575                            "expected ')' after EXPLAIN options, got {:?}",
576                            self.peek()
577                        )));
578                    }
579                    self.advance();
580                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
581                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
582                {
583                    self.advance();
584                    analyze = true;
585                }
586                let inner = self.parse_select_stmt()?;
587                let Statement::Select(s) = inner else {
588                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
589                };
590                Ok(Statement::Explain(crate::ast::ExplainStatement {
591                    analyze,
592                    inner: Box::new(s),
593                    suggest,
594                    costs_off,
595                }))
596            }
597            Token::Create => self.parse_create_stmt(),
598            Token::Insert => self.parse_insert_stmt(),
599            Token::Begin => {
600                self.advance();
601                Ok(Statement::Begin)
602            }
603            Token::Commit => {
604                self.advance();
605                Ok(Statement::Commit)
606            }
607            Token::Rollback => {
608                self.advance();
609                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
610                // savepoint without ending the transaction. Bare
611                // `ROLLBACK` drops the whole TX.
612                if matches!(self.peek(), Token::To) {
613                    self.advance();
614                    if matches!(self.peek(), Token::Savepoint) {
615                        self.advance();
616                    }
617                    let name = self.expect_ident_like()?;
618                    Ok(Statement::RollbackToSavepoint(name))
619                } else {
620                    Ok(Statement::Rollback)
621                }
622            }
623            Token::Savepoint => {
624                self.advance();
625                let name = self.expect_ident_like()?;
626                Ok(Statement::Savepoint(name))
627            }
628            Token::Release => {
629                self.advance();
630                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
631                // is optional in standard SQL.
632                if matches!(self.peek(), Token::Savepoint) {
633                    self.advance();
634                }
635                let name = self.expect_ident_like()?;
636                Ok(Statement::ReleaseSavepoint(name))
637            }
638            Token::Show => {
639                self.advance();
640                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
641                // v6.1.2 promoted TABLES to a reserved keyword (for
642                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
643                // arrives as `Token::Tables` rather than a bare ident.
644                // USERS / COLUMNS remain bare idents.
645                let target = match self.advance() {
646                    Token::Tables => "tables".to_string(),
647                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
648                    // keyword token; recognise it as the SHOW CREATE
649                    // dispatch keyword too.
650                    Token::Create => "create".to_string(),
651                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
652                    // keyword too; let SHOW INDEX FROM parse.
653                    Token::Index => "index".to_string(),
654                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
655                    other => {
656                        return Err(self.err(format!(
657                            "expected SHOW target, got {other:?}"
658                        )));
659                    }
660                };
661                match target.as_str() {
662                    "tables" => Ok(Statement::ShowTables),
663                    "users" => Ok(Statement::ShowUsers),
664                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
665                    // TABLE <t>` returns a 2-column row: (Table,
666                    // Create Table). mysqldump emits this for every
667                    // table at scrape time; without it the dump
668                    // round-trip stalls.
669                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
670                    // FROM <t>` (also spelled `SHOW INDEX` and
671                    // `SHOW KEYS`). admin / mysqldump probes use
672                    // it to list per-table indexes.
673                    "indexes" | "index" | "keys" => {
674                        if !matches!(self.peek(), Token::From) {
675                            return Err(self.err(format!(
676                                "expected FROM after SHOW INDEXES, got {:?}",
677                                self.peek()
678                            )));
679                        }
680                        self.advance();
681                        let table = self.expect_ident_like()?;
682                        Ok(Statement::ShowIndexes(table))
683                    }
684                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
685                    // `SHOW VARIABLES`. Both return a 2-column row
686                    // set listing server-side state; clients probe
687                    // them at connect time.
688                    "status" => Ok(Statement::ShowStatus),
689                    "variables" => Ok(Statement::ShowVariables),
690                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
691                    "processlist" => Ok(Statement::ShowProcesslist),
692                    "create" => {
693                        // SHOW CREATE TABLE / VIEW / DATABASE — only
694                        // TABLE is supported in v7.17.
695                        let kind = match self.advance() {
696                            Token::Ident(s) | Token::QuotedIdent(s) => s,
697                            Token::Table => "table".to_string(),
698                            other => {
699                                return Err(self.err(format!(
700                                    "expected TABLE after SHOW CREATE, got {other:?}"
701                                )));
702                            }
703                        };
704                        if !kind.eq_ignore_ascii_case("table") {
705                            return Err(self.err(format!(
706                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
707                            )));
708                        }
709                        let name = self.expect_ident_like()?;
710                        Ok(Statement::ShowCreateTable(name))
711                    }
712                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
713                    // (and `SHOW SCHEMAS` alias). The mysql client uses
714                    // it to populate the database selector at connect
715                    // time; without it `mysql -p` errors before the
716                    // first user query.
717                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
718                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
719                    // keyword on its own; it lands here as a bare
720                    // ident. Returning all publications + their
721                    // scope summary.
722                    "publications" => Ok(Statement::ShowPublications),
723                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
724                    "subscriptions" => Ok(Statement::ShowSubscriptions),
725                    "columns" => {
726                        if !matches!(self.peek(), Token::From) {
727                            return Err(self.err(format!(
728                                "expected FROM after SHOW COLUMNS, got {:?}",
729                                self.peek()
730                            )));
731                        }
732                        self.advance();
733                        let table = self.expect_ident_like()?;
734                        Ok(Statement::ShowColumns(table))
735                    }
736                    other => Err(self.err(format!(
737                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
738                    ))),
739                }
740            }
741            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
742            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
743            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
744            // arrived as a bare ident; tokenising it dedicatedly
745            // keeps the dispatch tree small.
746            Token::Drop => {
747                self.advance();
748                match self.peek() {
749                    Token::Publication => {
750                        self.advance();
751                        let name = self.expect_ident_or_string()?;
752                        Ok(Statement::DropPublication(name))
753                    }
754                    Token::Subscription => {
755                        self.advance();
756                        let name = self.expect_ident_or_string()?;
757                        Ok(Statement::DropSubscription(name))
758                    }
759                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
760                        self.advance();
761                        let name = self.expect_ident_or_string()?;
762                        Ok(Statement::DropUser(name))
763                    }
764                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
765                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
766                        self.advance();
767                        let if_exists = self.consume_if_exists();
768                        let name = self.expect_ident_like()?;
769                        // ON <table>
770                        if !matches!(self.peek(), Token::On) {
771                            return Err(self.err(alloc::format!(
772                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
773                                self.peek()
774                            )));
775                        }
776                        self.advance();
777                        let table = self.expect_ident_like()?;
778                        Ok(Statement::DropTrigger {
779                            name,
780                            table,
781                            if_exists,
782                        })
783                    }
784                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
785                    // v7.12.4 ignores any optional arg-list (signature-
786                    // based overload disambiguation lands in v7.12.5+).
787                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
788                        self.advance();
789                        let if_exists = self.consume_if_exists();
790                        let name = self.expect_ident_like()?;
791                        // Optional `()` — consume + discard.
792                        if matches!(self.peek(), Token::LParen) {
793                            self.advance();
794                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
795                            let mut depth = 1usize;
796                            while depth > 0 {
797                                match self.peek() {
798                                    Token::LParen => depth += 1,
799                                    Token::RParen => depth -= 1,
800                                    Token::Eof => {
801                                        return Err(self.err(alloc::format!(
802                                            "unterminated arg list in DROP FUNCTION {name:?}"
803                                        )));
804                                    }
805                                    _ => {}
806                                }
807                                self.advance();
808                            }
809                        }
810                        Ok(Statement::DropFunction { name, if_exists })
811                    }
812                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
813                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
814                    // emit DROP TABLE IF EXISTS at the head of every
815                    // CREATE TABLE block so re-importing a dump
816                    // overwrites prior state. SPG accepts and removes
817                    // matching tables; CASCADE/RESTRICT trailers
818                    // accepted silently.
819                    Token::Table => {
820                        self.advance();
821                        let if_exists = self.consume_if_exists();
822                        let mut names: Vec<String> = Vec::new();
823                        loop {
824                            names.push(self.expect_ident_like()?);
825                            if matches!(self.peek(), Token::Comma) {
826                                self.advance();
827                                continue;
828                            }
829                            break;
830                        }
831                        if matches!(
832                            self.peek(),
833                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
834                                || s.eq_ignore_ascii_case("restrict")
835                        ) {
836                            self.advance();
837                        }
838                        Ok(Statement::DropTable { names, if_exists })
839                    }
840                    // v7.14.0 — DROP INDEX [IF EXISTS] name
841                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
842                    // for partial-index renames and pgvector
843                    // migrations. SPG removes the matching index;
844                    // IF EXISTS makes the drop idempotent.
845                    Token::Index => {
846                        self.advance();
847                        let if_exists = self.consume_if_exists();
848                        let name = self.expect_ident_like()?;
849                        if matches!(
850                            self.peek(),
851                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
852                                || s.eq_ignore_ascii_case("restrict")
853                        ) {
854                            self.advance();
855                        }
856                        Ok(Statement::DropIndex { name, if_exists })
857                    }
858                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
859                    // [CASCADE|RESTRICT]. SPG is single-database;
860                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
861                    // name [, name…] [CASCADE | RESTRICT]. Real
862                    // unregister (was silent no-op pre-v7.17).
863                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
864                        self.advance();
865                        let if_exists = self.consume_if_exists();
866                        let mut names = vec![self.expect_ident_like()?];
867                        while matches!(self.peek(), Token::Comma) {
868                            self.advance();
869                            names.push(self.expect_ident_like()?);
870                        }
871                        if matches!(
872                            self.peek(),
873                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
874                                || s.eq_ignore_ascii_case("restrict")
875                        ) {
876                            self.advance();
877                        }
878                        Ok(Statement::DropSchema { names, if_exists })
879                    }
880                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
881                    // name [, name…] [CASCADE|RESTRICT].
882                    Token::Ident(s) | Token::QuotedIdent(s)
883                        if s.eq_ignore_ascii_case("type") =>
884                    {
885                        self.advance();
886                        let if_exists = self.consume_if_exists();
887                        let mut names = vec![self.expect_ident_like()?];
888                        while matches!(self.peek(), Token::Comma) {
889                            self.advance();
890                            names.push(self.expect_ident_like()?);
891                        }
892                        if matches!(
893                            self.peek(),
894                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
895                                || s.eq_ignore_ascii_case("restrict")
896                        ) {
897                            self.advance();
898                        }
899                        Ok(Statement::DropType { names, if_exists })
900                    }
901                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
902                    // name [, name…] [CASCADE|RESTRICT].
903                    Token::Ident(s) | Token::QuotedIdent(s)
904                        if s.eq_ignore_ascii_case("domain") =>
905                    {
906                        self.advance();
907                        let if_exists = self.consume_if_exists();
908                        let mut names = vec![self.expect_ident_like()?];
909                        while matches!(self.peek(), Token::Comma) {
910                            self.advance();
911                            names.push(self.expect_ident_like()?);
912                        }
913                        if matches!(
914                            self.peek(),
915                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
916                                || s.eq_ignore_ascii_case("restrict")
917                        ) {
918                            self.advance();
919                        }
920                        Ok(Statement::DropDomain { names, if_exists })
921                    }
922                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
923                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
924                    Token::Ident(s) | Token::QuotedIdent(s)
925                        if s.eq_ignore_ascii_case("materialized") =>
926                    {
927                        self.advance();
928                        let nxt = self.peek().clone();
929                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
930                        {
931                            return Err(self.err(alloc::format!(
932                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
933                            )));
934                        }
935                        self.advance();
936                        let if_exists = self.consume_if_exists();
937                        let mut names = vec![self.expect_ident_like()?];
938                        while matches!(self.peek(), Token::Comma) {
939                            self.advance();
940                            names.push(self.expect_ident_like()?);
941                        }
942                        if matches!(
943                            self.peek(),
944                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
945                                || s.eq_ignore_ascii_case("restrict")
946                        ) {
947                            self.advance();
948                        }
949                        Ok(Statement::DropMaterializedView { names, if_exists })
950                    }
951                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
952                    // name [, name…] [CASCADE|RESTRICT].
953                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
954                        self.advance();
955                        let if_exists = self.consume_if_exists();
956                        let mut names = vec![self.expect_ident_like()?];
957                        while matches!(self.peek(), Token::Comma) {
958                            self.advance();
959                            names.push(self.expect_ident_like()?);
960                        }
961                        if matches!(
962                            self.peek(),
963                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
964                                || s.eq_ignore_ascii_case("restrict")
965                        ) {
966                            self.advance();
967                        }
968                        Ok(Statement::DropView { names, if_exists })
969                    }
970                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
971                    // [CASCADE|RESTRICT]. Real removal from catalog
972                    // (was a silent no-op pre-v7.17).
973                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
974                        self.advance();
975                        let if_exists = self.consume_if_exists();
976                        let mut names = vec![self.expect_ident_like()?];
977                        while matches!(self.peek(), Token::Comma) {
978                            self.advance();
979                            names.push(self.expect_ident_like()?);
980                        }
981                        if matches!(
982                            self.peek(),
983                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
984                                || s.eq_ignore_ascii_case("restrict")
985                        ) {
986                            self.advance();
987                        }
988                        Ok(Statement::DropSequence { names, if_exists })
989                    }
990                    other => Err(self.err(format!(
991                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
992                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
993                    ))),
994                }
995            }
996            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
997            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
998                self.advance();
999                let nxt = self.peek().clone();
1000                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
1001                {
1002                    return Err(self.err(alloc::format!(
1003                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
1004                    )));
1005                }
1006                self.advance();
1007                let nxt2 = self.peek().clone();
1008                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1009                {
1010                    return Err(self.err(alloc::format!(
1011                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
1012                    )));
1013                }
1014                self.advance();
1015                let name = self.expect_ident_like()?;
1016                let with_data = self.parse_optional_with_data(true)?;
1017                Ok(Statement::RefreshMaterializedView { name, with_data })
1018            }
1019            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
1020                self.advance();
1021                self.parse_update_after_keyword()
1022            }
1023            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
1024                self.advance();
1025                self.parse_delete_after_keyword()
1026            }
1027            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
1028            // ALTER is not a reserved keyword in the lexer — handled
1029            // as a bare ident here.
1030            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
1031                self.advance();
1032                self.parse_alter_after_keyword()
1033            }
1034            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
1035            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
1036            // additions needed.
1037            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
1038                self.advance();
1039                self.parse_wait_after_keyword()
1040            }
1041            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
1042            // Bare ANALYZE → analyse every user table; ANALYZE
1043            // <name> → re-stats one. The argument is an optional
1044            // ident (or quoted ident); anything else is a parse
1045            // error.
1046            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
1047            // `WHERE` filter (carved out per V6_7_DESIGN.md
1048            // STABILITY). Lex order: identifier "compact" → "cold"
1049            // → "segments". Anything else after `COMPACT` is a
1050            // parse error.
1051            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
1052                self.advance();
1053                let next = self.peek().clone();
1054                let cold = match next {
1055                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1056                    _ => {
1057                        return Err(
1058                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
1059                        );
1060                    }
1061                };
1062                if !cold.eq_ignore_ascii_case("cold") {
1063                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
1064                }
1065                self.advance();
1066                let next = self.peek().clone();
1067                let segments = match next {
1068                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1069                    _ => {
1070                        return Err(self.err(format!(
1071                            "expected SEGMENTS after COMPACT COLD, got {:?}",
1072                            self.peek()
1073                        )));
1074                    }
1075                };
1076                if !segments.eq_ignore_ascii_case("segments") {
1077                    return Err(self.err(format!(
1078                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
1079                    )));
1080                }
1081                self.advance();
1082                Ok(Statement::CompactColdSegments)
1083            }
1084            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
1085            // Parsed as a case-insensitive identifier since MERGE
1086            // isn't a reserved lexer keyword (collides with the
1087            // mysqldump `ALGORITHM = MERGE` view clause if it
1088            // were); the inner parser drives the rest of the
1089            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
1090            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
1091                self.advance();
1092                self.parse_merge_after_keyword()
1093            }
1094            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
1095                self.advance();
1096                let target = match self.peek() {
1097                    Token::Eof | Token::Semicolon => None,
1098                    Token::Ident(_) | Token::QuotedIdent(_) => {
1099                        Some(self.expect_ident_like()?)
1100                    }
1101                    other => {
1102                        return Err(self.err(format!(
1103                            "expected table name or end of statement after ANALYZE, got {other:?}"
1104                        )));
1105                    }
1106                };
1107                Ok(Statement::Analyze(target))
1108            }
1109            // v7.12.1 — `SET <name> [TO|=] <value>`. The
1110            // `default_text_search_config` parameter is consumed
1111            // by the FTS function dispatcher; other parameter
1112            // names are recorded but treated as a no-op so PG
1113            // dump output loads.
1114            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
1115                self.advance();
1116                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
1117                // — accept and ignore. MySQL adds `SET GLOBAL` too
1118                // (and the alias `SET @@global.name = …` which the
1119                // SessionVar path handles).
1120                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"))
1121                {
1122                    self.advance();
1123                }
1124                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
1125                // <collation>]` — change the connection client
1126                // charset. SPG stores UTF-8 always and orders
1127                // bytewise; accept as a no-op.
1128                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
1129                {
1130                    self.advance();
1131                    // Charset ident-or-string.
1132                    if matches!(
1133                        self.peek(),
1134                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1135                    ) {
1136                        self.advance();
1137                    }
1138                    // Optional `COLLATE <name>`.
1139                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
1140                    {
1141                        self.advance();
1142                        if matches!(
1143                            self.peek(),
1144                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1145                        ) {
1146                            self.advance();
1147                        }
1148                    }
1149                    return Ok(Statement::Empty);
1150                }
1151                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
1152                // { DEFAULT | '<role>' | <ident> }` (mailrs
1153                // round-10 A.1). pg_dump preamble emits the
1154                // `DEFAULT` form to reset session authorization;
1155                // SPG has no role system so this is a strict
1156                // no-op. PG also accepts `RESET SESSION
1157                // AUTHORIZATION` (handled by the RESET parser
1158                // elsewhere). Reference:
1159                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
1160                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
1161                {
1162                    self.advance(); // AUTHORIZATION
1163                    match self.peek().clone() {
1164                        Token::Default => {
1165                            self.advance();
1166                        }
1167                        Token::String(_)
1168                        | Token::Ident(_)
1169                        | Token::QuotedIdent(_) => {
1170                            self.advance();
1171                        }
1172                        other => {
1173                            return Err(self.err(alloc::format!(
1174                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
1175                            )));
1176                        }
1177                    }
1178                    return Ok(Statement::Empty);
1179                }
1180                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
1181                // alias — same accept-as-no-op as SET NAMES.
1182                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
1183                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
1184                {
1185                    self.advance(); // CHARACTER
1186                    self.advance(); // SET
1187                    if matches!(
1188                        self.peek(),
1189                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1190                    ) {
1191                        self.advance();
1192                    }
1193                    return Ok(Statement::Empty);
1194                }
1195                // v7.14.0 — multi-assignment form
1196                // `SET a = 1, b = 2, …`. Single-assignment is the
1197                // 1-element case. Each LHS may be a regular ident
1198                // or a SessionVar (`@VAR` / `@@VAR`).
1199                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
1200                loop {
1201                    let lhs = match self.peek().clone() {
1202                        Token::SessionVar(s) => {
1203                            self.advance();
1204                            s
1205                        }
1206                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
1207                        other => {
1208                            return Err(self.err(format!(
1209                                "expected parameter name after SET, got {other:?}"
1210                            )));
1211                        }
1212                    };
1213                    // Accept either `=` or the bare `TO` keyword.
1214                    match self.peek() {
1215                        Token::Eq => {
1216                            self.advance();
1217                        }
1218                        Token::To => {
1219                            self.advance();
1220                        }
1221                        other => {
1222                            return Err(self.err(format!(
1223                                "expected `=` or TO after SET {lhs}, got {other:?}"
1224                            )));
1225                        }
1226                    }
1227                    let value = self.parse_set_value()?;
1228                    pairs.push((lhs, value));
1229                    if matches!(self.peek(), Token::Comma) {
1230                        self.advance();
1231                        continue;
1232                    }
1233                    break;
1234                }
1235                if pairs.len() == 1 {
1236                    let (name, value) = pairs.into_iter().next().unwrap();
1237                    Ok(Statement::SetParameter { name, value })
1238                } else {
1239                    Ok(Statement::SetParameterList(pairs))
1240                }
1241            }
1242            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1243            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1244                self.advance();
1245                match self.peek().clone() {
1246                    Token::All => {
1247                        self.advance();
1248                        Ok(Statement::ResetParameter(None))
1249                    }
1250                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1251                        self.advance();
1252                        Ok(Statement::ResetParameter(None))
1253                    }
1254                    _ => {
1255                        let name = self.parse_set_param_name()?;
1256                        Ok(Statement::ResetParameter(Some(name)))
1257                    }
1258                }
1259            }
1260            other => Err(self.err(format!(
1261                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1262                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1263            ))),
1264        }
1265    }
1266
1267    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1268        debug_assert!(matches!(self.peek(), Token::Create));
1269        self.advance();
1270        match self.peek() {
1271            Token::Table => self.parse_create_table_stmt_after_create(),
1272            Token::Index => self.parse_create_index_stmt_after_create(false),
1273            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1274            // The `UNIQUE` modifier turns a partial index into a
1275            // partial-uniqueness invariant (only rows matching the
1276            // WHERE predicate are checked for duplicates). mailrs
1277            // K1 (3 hits: email_templates default, calendar_events
1278            // master, calendar_events instance).
1279            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1280                self.advance();
1281                if !matches!(self.peek(), Token::Index) {
1282                    return Err(self.err(alloc::format!(
1283                        "expected INDEX after CREATE UNIQUE, got {:?}",
1284                        self.peek()
1285                    )));
1286                }
1287                self.parse_create_index_stmt_after_create(true)
1288            }
1289            Token::Publication => {
1290                self.advance();
1291                self.parse_create_publication_after_keyword()
1292            }
1293            Token::Subscription => {
1294                self.advance();
1295                self.parse_create_subscription_after_keyword()
1296            }
1297            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1298            // USER isn't a reserved keyword — we look for the bare
1299            // identifier so the lexer doesn't have to grow a token.
1300            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1301                self.advance();
1302                self.parse_create_user_after_keyword()
1303            }
1304            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1305            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1306            // no-op. mailrs follow-up F3.
1307            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1308                self.advance();
1309                self.parse_create_extension_after_keyword()
1310            }
1311            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1312            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1313            // optional; absorb it here and forward to the
1314            // per-kind parsers with the flag. OR is a reserved
1315            // keyword token.
1316            Token::Or => {
1317                self.advance();
1318                let next = self.peek();
1319                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1320                    return Err(self.err(alloc::format!(
1321                        "expected REPLACE after CREATE OR, got {next:?}"
1322                    )));
1323                };
1324                if !s2.eq_ignore_ascii_case("replace") {
1325                    return Err(self.err(alloc::format!(
1326                        "expected REPLACE after CREATE OR, got {s2:?}"
1327                    )));
1328                }
1329                self.advance();
1330                self.parse_create_function_or_trigger_after_or_replace(true)
1331            }
1332            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1333                self.advance();
1334                self.parse_create_function_after_keyword(false)
1335            }
1336            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1337                self.advance();
1338                self.parse_create_trigger_after_keyword(false)
1339            }
1340            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1341            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1342                self.advance();
1343                self.parse_create_sequence_after_keyword(false)
1344            }
1345            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1346            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1347                self.advance();
1348                self.parse_create_view_after_keyword(false, false, false)
1349            }
1350            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1351            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1352            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1353            // appear (in any order) between `CREATE` and `VIEW` in
1354            // every mysqldump-emitted view. Pre-2.6 the parser
1355            // rejected the prefix and the customer's whole view
1356            // backup failed on the first view. The hints are pure
1357            // planner / permission metadata; SPG's view-rewrite
1358            // path is semantically equivalent for all three
1359            // algorithms in v7.17 (TEMPTABLE differs only in
1360            // perf for huge views — out of v7.17 scope), and
1361            // DEFINER / SQL SECURITY are pure single-user
1362            // permissioning that SPG ignores by design.
1363            Token::Ident(s) | Token::QuotedIdent(s)
1364                if s.eq_ignore_ascii_case("algorithm")
1365                    || s.eq_ignore_ascii_case("definer")
1366                    || s.eq_ignore_ascii_case("sql") =>
1367            {
1368                self.consume_mysql_view_prefix()?;
1369                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1370                // (in any order, in any combination), the next
1371                // keyword must be VIEW. mysqldump never emits these
1372                // prefixes on non-view statements.
1373                let next = self.peek().clone();
1374                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1375                    if s2.eq_ignore_ascii_case("view"))
1376                {
1377                    self.advance();
1378                    self.parse_create_view_after_keyword(false, false, false)
1379                } else {
1380                    Err(self.err(alloc::format!(
1381                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1382                    )))
1383                }
1384            }
1385            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1386            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1387                self.advance();
1388                self.parse_create_type_after_keyword()
1389            }
1390            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1391            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1392            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1393                self.advance();
1394                self.parse_create_domain_after_keyword()
1395            }
1396            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1397            // name [AUTHORIZATION user]. Real catalog registry
1398            // (was silent-no-op'd pre-v7.17).
1399            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1400                self.advance();
1401                let if_not_exists = self.parse_if_not_exists();
1402                let name = self.expect_ident_like()?;
1403                // Optional `AUTHORIZATION <user>` trailer — accepted,
1404                // ignored (single-user catalog).
1405                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1406                    if s.eq_ignore_ascii_case("authorization"))
1407                {
1408                    self.advance();
1409                    let _ = self.expect_ident_like()?;
1410                }
1411                Ok(Statement::CreateSchema { name, if_not_exists })
1412            }
1413            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1414            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1415                self.advance();
1416                let next = self.peek().clone();
1417                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1418                {
1419                    self.advance();
1420                    self.parse_create_materialized_view_after_keyword()
1421                } else {
1422                    Err(self.err(alloc::format!(
1423                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1424                    )))
1425                }
1426            }
1427            Token::Ident(s) | Token::QuotedIdent(s)
1428                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1429            {
1430                self.advance();
1431                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1432                let next = self.peek().clone();
1433                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1434                {
1435                    self.advance();
1436                    self.parse_create_sequence_after_keyword(true)
1437                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1438                {
1439                    self.advance();
1440                    self.parse_create_view_after_keyword(false, false, true)
1441                } else {
1442                    // TEMP TABLE etc — consume to boundary as noop for now.
1443                    self.consume_until_statement_boundary();
1444                    Ok(Statement::Empty)
1445                }
1446            }
1447            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1448            // BEGIN <body> END`. The body may reference `@var`
1449            // session variables, SET statements, internal `;`
1450            // terminators, etc. SPG has no procedure runtime, so
1451            // consume the whole `CREATE PROCEDURE … END` block as
1452            // a no-op so mysqldump scripts that include stored
1453            // routines load through. The matching-END consumer
1454            // tracks BEGIN/END nesting depth to handle nested
1455            // BEGIN blocks correctly.
1456            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1457                self.consume_mysql_routine_body();
1458                Ok(Statement::Empty)
1459            }
1460            // v7.14.0 — pg_dump / mysqldump emit
1461            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1462            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1463            // SPG is single-schema / single-database; these have
1464            // no behavioural effect, so consume + return Empty.
1465            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1466            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1467            // moved up to real parser branches. DATABASE / ROLE /
1468            // POLICY / OPERATOR stay no-op forever
1469            // (single-database, hardcoded roles).
1470            Token::Ident(s) | Token::QuotedIdent(s)
1471                if matches!(
1472                    s.to_ascii_lowercase().as_str(),
1473                    "database"
1474                        | "role"
1475                        | "policy"
1476                        | "operator"
1477                        | "cast"
1478                        | "rule"
1479                        | "aggregate"
1480                        | "language"
1481                        | "collation"
1482                        | "conversion"
1483                        // v7.17.0 Phase 8 (audit N6) — rarely-
1484                        // emitted pg_dump shapes that should
1485                        // load through without a parser error.
1486                        // SPG has no planner statistics catalog,
1487                        // no event-trigger hooks, no foreign-
1488                        // data-wrapper infrastructure; consume
1489                        // + return Empty.
1490                        | "statistics"
1491                        | "event"
1492                        | "foreign"
1493                ) =>
1494            {
1495                self.consume_until_statement_boundary();
1496                Ok(Statement::Empty)
1497            }
1498            other => Err(self.err(format!(
1499                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1500            ))),
1501        }
1502    }
1503
1504    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1505    /// keyword decides whether we parse a function or trigger
1506    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1507    /// PROCEDURE) — those land in later releases.
1508    fn parse_create_function_or_trigger_after_or_replace(
1509        &mut self,
1510        or_replace: bool,
1511    ) -> Result<Statement, ParseError> {
1512        let tok = self.peek();
1513        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1514            return Err(self.err(alloc::format!(
1515                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1516            )));
1517        };
1518        if s.eq_ignore_ascii_case("function") {
1519            self.advance();
1520            self.parse_create_function_after_keyword(or_replace)
1521        } else if s.eq_ignore_ascii_case("trigger") {
1522            self.advance();
1523            self.parse_create_trigger_after_keyword(or_replace)
1524        } else if s.eq_ignore_ascii_case("view") {
1525            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1526            self.advance();
1527            self.parse_create_view_after_keyword(or_replace, false, false)
1528        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1529            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1530            self.advance();
1531            let nxt = self.peek().clone();
1532            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1533            {
1534                self.advance();
1535                self.parse_create_view_after_keyword(or_replace, false, true)
1536            } else {
1537                Err(self.err(alloc::format!(
1538                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1539                )))
1540            }
1541        } else {
1542            Err(self.err(alloc::format!(
1543                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1544            )))
1545        }
1546    }
1547
1548    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1549    /// SPG doesn't have a registry; pgvector / similar are
1550    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1551    /// the syntax lets dual-target schemas keep the line.
1552    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1553        // Optional `IF NOT EXISTS`.
1554        self.consume_if_not_exists();
1555        let name = self.expect_ident_like()?;
1556        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1557        // CASCADE / FROM '<v>' clauses; we don't model them.
1558        loop {
1559            match self.peek() {
1560                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1561                    self.advance();
1562                    continue;
1563                }
1564                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1565                    self.advance();
1566                    let _ = self.expect_ident_like()?;
1567                    continue;
1568                }
1569                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1570                    self.advance();
1571                    // String or ident literal.
1572                    let _ = self.advance();
1573                    continue;
1574                }
1575                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1576                    self.advance();
1577                    let _ = self.advance();
1578                    continue;
1579                }
1580                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1581                    self.advance();
1582                    continue;
1583                }
1584                _ => break,
1585            }
1586        }
1587        Ok(Statement::CreateExtension(name))
1588    }
1589
1590    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1591    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1592    /// already been consumed by the caller. Grammar accepted:
1593    ///
1594    ///   name `(` arg-list `)`
1595    ///   `RETURNS` return-type
1596    ///   [ `LANGUAGE` ident ]
1597    ///   `AS` $$ body $$
1598    ///   [ `LANGUAGE` ident ]
1599    ///
1600    /// Either `LANGUAGE` position is allowed; PG accepts both.
1601    fn parse_create_function_after_keyword(
1602        &mut self,
1603        or_replace: bool,
1604    ) -> Result<Statement, ParseError> {
1605        let name = self.expect_ident_like()?;
1606        // Argument list. v7.12.4 commonly sees the empty `()`
1607        // (trigger functions); typed args parse and round-trip
1608        // but the executor only invokes nullary functions.
1609        if !matches!(self.peek(), Token::LParen) {
1610            return Err(self.err(alloc::format!(
1611                "expected '(' after function name {name:?}, got {:?}",
1612                self.peek()
1613            )));
1614        }
1615        self.advance();
1616        let args = self.parse_function_arg_list()?;
1617        // RETURNS clause.
1618        let tok = self.peek();
1619        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1620            return Err(self.err(alloc::format!(
1621                "expected RETURNS after function arg list, got {tok:?}"
1622            )));
1623        };
1624        if !s.eq_ignore_ascii_case("returns") {
1625            return Err(self.err(alloc::format!(
1626                "expected RETURNS after function arg list, got {s:?}"
1627            )));
1628        }
1629        self.advance();
1630        let returns = self.parse_function_return()?;
1631        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1632        // re-check after the body too).
1633        let mut language: Option<String> = self.parse_optional_language()?;
1634        // `AS` followed by a $$-quoted body (lexer already
1635        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1636        // Token::String). AS is a reserved keyword (Token::As).
1637        if !matches!(self.peek(), Token::As) {
1638            return Err(self.err(alloc::format!(
1639                "expected AS before function body, got {:?}",
1640                self.peek()
1641            )));
1642        }
1643        self.advance();
1644        let body_text = match self.peek() {
1645            Token::String(s) => {
1646                let body = s.clone();
1647                self.advance();
1648                body
1649            }
1650            other => {
1651                return Err(self.err(alloc::format!(
1652                    "expected $$-quoted function body after AS, got {other:?}"
1653                )));
1654            }
1655        };
1656        // Trailing optional LANGUAGE clause (the other PG position).
1657        if language.is_none() {
1658            language = self.parse_optional_language()?;
1659        }
1660        let language = language.unwrap_or_else(|| String::from("sql"));
1661        // PL/pgSQL bodies get structure-parsed. Other languages
1662        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1663        // recognise) round-trip as Raw text — the executor errors
1664        // when invoked with a clear unsupported message.
1665        let body = if language.eq_ignore_ascii_case("plpgsql") {
1666            match parse_plpgsql_body(&body_text) {
1667                Ok(block) => FunctionBody::PlPgSql(block),
1668                // Best-effort: if the body parser doesn't yet
1669                // support a construct used inside, fall back to
1670                // raw — keeps `CREATE FUNCTION` itself working
1671                // (catalogue accepts), executor errors on
1672                // invocation only.
1673                Err(_) => FunctionBody::Raw(body_text),
1674            }
1675        } else {
1676            FunctionBody::Raw(body_text)
1677        };
1678        Ok(Statement::CreateFunction(CreateFunctionStatement {
1679            name,
1680            or_replace,
1681            args,
1682            returns,
1683            language,
1684            body,
1685        }))
1686    }
1687
1688    /// Closing `)`-terminated argument list. v7.12.4 commonly
1689    /// sees the empty `()`; typed args round-trip but the
1690    /// executor (yet) doesn't invoke them.
1691    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1692        let mut args: Vec<FunctionArg> = Vec::new();
1693        if matches!(self.peek(), Token::RParen) {
1694            self.advance();
1695            return Ok(args);
1696        }
1697        loop {
1698            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1699            // a reserved token; OUT / INOUT are bare idents.
1700            let mode = if matches!(self.peek(), Token::In) {
1701                self.advance();
1702                FunctionArgMode::In
1703            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1704            {
1705                self.advance();
1706                FunctionArgMode::Out
1707            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1708            {
1709                self.advance();
1710                FunctionArgMode::InOut
1711            } else {
1712                FunctionArgMode::In
1713            };
1714            // Optional name. The next token is either a name
1715            // (followed by a type ident) or the type itself.
1716            // Disambiguate by peeking ahead: if the token after
1717            // the next ident is also an ident, we treat the
1718            // first as the name.
1719            let (name, ty_token) = {
1720                let first = self.expect_ident_like()?;
1721                // Peek next: if it's an ident (i.e. a type
1722                // name) the `first` was the arg name.
1723                match self.peek() {
1724                    Token::Ident(_) | Token::QuotedIdent(_) => {
1725                        let ty = self.expect_ident_like()?;
1726                        (Some(first), ty)
1727                    }
1728                    _ => (None, first),
1729                }
1730            };
1731            // Type — try to map to ColumnTypeName, else Raw.
1732            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1733                Some(t) => FunctionArgType::Typed(t),
1734                None => FunctionArgType::Raw(ty_token),
1735            };
1736            args.push(FunctionArg { mode, name, ty });
1737            match self.peek() {
1738                Token::Comma => {
1739                    self.advance();
1740                    continue;
1741                }
1742                Token::RParen => {
1743                    self.advance();
1744                    return Ok(args);
1745                }
1746                other => {
1747                    return Err(self.err(alloc::format!(
1748                        "expected , or ) in function arg list, got {other:?}"
1749                    )));
1750                }
1751            }
1752        }
1753    }
1754
1755    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1756        let ident = self.expect_ident_like()?;
1757        if ident.eq_ignore_ascii_case("trigger") {
1758            return Ok(FunctionReturn::Trigger);
1759        }
1760        if ident.eq_ignore_ascii_case("void") {
1761            return Ok(FunctionReturn::Void);
1762        }
1763        match map_type_ident_to_column_type_name(&ident) {
1764            Some(t) => Ok(FunctionReturn::Type(t)),
1765            None => Ok(FunctionReturn::Other(ident)),
1766        }
1767    }
1768
1769    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1770        match self.peek() {
1771            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1772                self.advance();
1773                let lang = self.expect_ident_like()?;
1774                Ok(Some(lang.to_ascii_lowercase()))
1775            }
1776            _ => Ok(None),
1777        }
1778    }
1779
1780    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1781    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1782    /// (expr)]*`. The `DOMAIN` keyword has already been
1783    /// consumed. PG allows the trailing constraints in any
1784    /// order; we approximate with a small loop.
1785    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1786        let name = self.expect_ident_like()?;
1787        // Optional `AS`.
1788        if matches!(self.peek(), Token::As) {
1789            self.advance();
1790        }
1791        let base_type = self.parse_column_type_name()?;
1792        let mut default: Option<Expr> = None;
1793        let mut not_null = false;
1794        let mut checks: Vec<Expr> = Vec::new();
1795        loop {
1796            match self.peek() {
1797                Token::Default => {
1798                    if default.is_some() {
1799                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1800                    }
1801                    self.advance();
1802                    default = Some(self.parse_expr(0)?);
1803                }
1804                Token::Not => {
1805                    self.advance();
1806                    if !matches!(self.peek(), Token::Null) {
1807                        return Err(self.err(alloc::format!(
1808                            "expected NULL after NOT in DOMAIN, got {:?}",
1809                            self.peek()
1810                        )));
1811                    }
1812                    self.advance();
1813                    not_null = true;
1814                }
1815                Token::Null => {
1816                    self.advance();
1817                    // NULL after a NOT NULL is contradictory, but
1818                    // PG accepts bare NULL as the default-nullable
1819                    // marker. No-op.
1820                }
1821                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1822                    self.advance();
1823                    if !matches!(self.peek(), Token::LParen) {
1824                        return Err(self.err(alloc::format!(
1825                            "expected '(' after CHECK in DOMAIN, got {:?}",
1826                            self.peek()
1827                        )));
1828                    }
1829                    self.advance();
1830                    let expr = self.parse_expr(0)?;
1831                    if !matches!(self.peek(), Token::RParen) {
1832                        return Err(self.err(alloc::format!(
1833                            "expected ')' after CHECK expr, got {:?}",
1834                            self.peek()
1835                        )));
1836                    }
1837                    self.advance();
1838                    checks.push(expr);
1839                }
1840                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1841                // prefix on the constraint; we drop the name and
1842                // recurse into the constraint parsing.
1843                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1844                    self.advance();
1845                    let _ = self.expect_ident_like()?;
1846                }
1847                _ => break,
1848            }
1849        }
1850        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1851            name,
1852            base_type,
1853            default,
1854            not_null,
1855            checks,
1856        }))
1857    }
1858
1859    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1860    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1861    /// consumed.
1862    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1863        let name = self.expect_ident_like()?;
1864        // Required `AS`.
1865        if !matches!(self.peek(), Token::As) {
1866            return Err(self.err(alloc::format!(
1867                "expected AS after CREATE TYPE {name:?}, got {:?}",
1868                self.peek()
1869            )));
1870        }
1871        self.advance();
1872        // v7.37.x (ζ-B composite Phase 1) — `AS (` is the composite-
1873        // type shape: `CREATE TYPE foo AS (a INT, b TEXT)`. Branch
1874        // on the next token: `(` = composite, ident `ENUM` = enum.
1875        if matches!(self.peek(), Token::LParen) {
1876            self.advance();
1877            let mut fields: Vec<(String, ColumnTypeName)> = Vec::new();
1878            loop {
1879                let field_name = self.expect_ident_like()?;
1880                let field_type = self.parse_column_type_name()?;
1881                fields.push((field_name, field_type));
1882                if matches!(self.peek(), Token::Comma) {
1883                    self.advance();
1884                    continue;
1885                }
1886                if matches!(self.peek(), Token::RParen) {
1887                    self.advance();
1888                    break;
1889                }
1890                return Err(self.err(alloc::format!(
1891                    "expected , or ) in composite field list, got {:?}",
1892                    self.peek()
1893                )));
1894            }
1895            if fields.is_empty() {
1896                return Err(self.err("CREATE TYPE … AS (…) must declare at least one field".into()));
1897            }
1898            return Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1899                name,
1900                kind: crate::ast::TypeKind::Composite { fields },
1901            }));
1902        }
1903        // Required `ENUM` ident.
1904        let kind_ident = match self.peek().clone() {
1905            Token::Ident(s) | Token::QuotedIdent(s) => s,
1906            other => {
1907                return Err(self.err(alloc::format!(
1908                    "expected ENUM or '(' after CREATE TYPE {name:?} AS, got {other:?}"
1909                )));
1910            }
1911        };
1912        if !kind_ident.eq_ignore_ascii_case("enum") {
1913            return Err(self.err(alloc::format!(
1914                "Phase 1.4 only supports ENUM or composite '(…)'; got {kind_ident:?}"
1915            )));
1916        }
1917        self.advance();
1918        if !matches!(self.peek(), Token::LParen) {
1919            return Err(self.err(alloc::format!(
1920                "expected '(' after ENUM, got {:?}",
1921                self.peek()
1922            )));
1923        }
1924        self.advance();
1925        let mut labels: Vec<String> = Vec::new();
1926        loop {
1927            match self.peek().clone() {
1928                Token::String(s) => {
1929                    self.advance();
1930                    labels.push(s);
1931                }
1932                other => {
1933                    return Err(
1934                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1935                    );
1936                }
1937            }
1938            if matches!(self.peek(), Token::Comma) {
1939                self.advance();
1940                continue;
1941            }
1942            if matches!(self.peek(), Token::RParen) {
1943                self.advance();
1944                break;
1945            }
1946            return Err(self.err(alloc::format!(
1947                "expected , or ) in ENUM label list, got {:?}",
1948                self.peek()
1949            )));
1950        }
1951        if labels.is_empty() {
1952            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
1953        }
1954        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1955            name,
1956            kind: crate::ast::TypeKind::Enum { labels },
1957        }))
1958    }
1959
1960    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
1961    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
1962    /// The `CREATE MATERIALIZED VIEW` keywords have already been
1963    /// consumed.
1964    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
1965        let if_not_exists = self.parse_if_not_exists();
1966        let name = self.expect_ident_like()?;
1967        let mut columns: Vec<String> = Vec::new();
1968        if matches!(self.peek(), Token::LParen) {
1969            self.advance();
1970            loop {
1971                let c = self.expect_ident_like()?;
1972                columns.push(c);
1973                if matches!(self.peek(), Token::Comma) {
1974                    self.advance();
1975                    continue;
1976                }
1977                if matches!(self.peek(), Token::RParen) {
1978                    self.advance();
1979                    break;
1980                }
1981                return Err(self.err(alloc::format!(
1982                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
1983                    self.peek()
1984                )));
1985            }
1986        }
1987        if !matches!(self.peek(), Token::As) {
1988            return Err(self.err(alloc::format!(
1989                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
1990                self.peek()
1991            )));
1992        }
1993        self.advance();
1994        let body_stmt = self.parse_select_stmt()?;
1995        let Statement::Select(body) = body_stmt else {
1996            return Err(self.err(alloc::format!(
1997                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
1998            )));
1999        };
2000        // Optional trailing `WITH [NO] DATA`.
2001        let with_data = self.parse_optional_with_data(true)?;
2002        Ok(Statement::CreateMaterializedView(
2003            crate::ast::CreateMaterializedViewStatement {
2004                name,
2005                if_not_exists,
2006                columns,
2007                body,
2008                with_data,
2009            },
2010        ))
2011    }
2012
2013    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
2014    /// `default_when_absent` is what to return if the tail is
2015    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
2016    /// WITH DATA).
2017    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
2018        let save = self.pos;
2019        // `WITH` is an Ident (not reserved in the lexer).
2020        let is_with = match self.peek() {
2021            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
2022            _ => false,
2023        };
2024        if !is_with {
2025            return Ok(default_when_absent);
2026        }
2027        self.advance();
2028        // Optional `NO`.
2029        let mut with_data = true;
2030        let is_no = match self.peek() {
2031            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
2032            _ => false,
2033        };
2034        if is_no {
2035            self.advance();
2036            with_data = false;
2037        }
2038        // Required `DATA` ident.
2039        let is_data = match self.peek() {
2040            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
2041            _ => false,
2042        };
2043        if is_data {
2044            self.advance();
2045            Ok(with_data)
2046        } else {
2047            // Caller's WITH wasn't WITH-DATA — rewind so the outer
2048            // parser can interpret it.
2049            self.pos = save;
2050            Ok(default_when_absent)
2051        }
2052    }
2053
2054    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
2055    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
2056    /// All keyword prefixes have already been consumed; the flags
2057    /// say which were present.
2058    fn parse_create_view_after_keyword(
2059        &mut self,
2060        or_replace: bool,
2061        _materialized_unused: bool,
2062        temporary: bool,
2063    ) -> Result<Statement, ParseError> {
2064        let if_not_exists = self.parse_if_not_exists();
2065        let name = self.expect_ident_like()?;
2066        // Optional `(col, col, …)` rename list.
2067        let mut columns: Vec<String> = Vec::new();
2068        if matches!(self.peek(), Token::LParen) {
2069            self.advance();
2070            loop {
2071                let c = self.expect_ident_like()?;
2072                columns.push(c);
2073                if matches!(self.peek(), Token::Comma) {
2074                    self.advance();
2075                    continue;
2076                }
2077                if matches!(self.peek(), Token::RParen) {
2078                    self.advance();
2079                    break;
2080                }
2081                return Err(self.err(alloc::format!(
2082                    "expected , or ) in VIEW column list, got {:?}",
2083                    self.peek()
2084                )));
2085            }
2086        }
2087        // Required `AS`.
2088        if !matches!(self.peek(), Token::As) {
2089            return Err(self.err(alloc::format!(
2090                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
2091                self.peek()
2092            )));
2093        }
2094        self.advance();
2095        // Body: a regular SELECT statement.
2096        let body_stmt = self.parse_select_stmt()?;
2097        let Statement::Select(body) = body_stmt else {
2098            return Err(self.err(alloc::format!(
2099                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
2100            )));
2101        };
2102        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
2103            name,
2104            or_replace,
2105            if_not_exists,
2106            temporary,
2107            columns,
2108            body,
2109        }))
2110    }
2111
2112    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
2113    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
2114    /// consumed; `temporary` carries whether TEMPORARY was seen.
2115    fn parse_create_sequence_after_keyword(
2116        &mut self,
2117        temporary: bool,
2118    ) -> Result<Statement, ParseError> {
2119        let if_not_exists = self.parse_if_not_exists();
2120        let name = self.expect_ident_like()?;
2121        // Optional `AS data_type`.
2122        let data_type = if matches!(self.peek(), Token::As) {
2123            self.advance();
2124            Some(self.parse_sequence_data_type()?)
2125        } else {
2126            None
2127        };
2128        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
2129        Ok(Statement::CreateSequence(
2130            crate::ast::CreateSequenceStatement {
2131                name,
2132                if_not_exists,
2133                temporary,
2134                data_type,
2135                options,
2136            },
2137        ))
2138    }
2139
2140    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
2141    /// already been consumed; this is reached after `SEQUENCE`.
2142    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
2143        let if_exists = self.parse_if_exists();
2144        let name = self.expect_ident_like()?;
2145        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
2146        Ok(Statement::AlterSequence(
2147            crate::ast::AlterSequenceStatement {
2148                name,
2149                if_exists,
2150                options,
2151            },
2152        ))
2153    }
2154
2155    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
2156        let kw = self.expect_ident_like()?;
2157        match kw.to_ascii_lowercase().as_str() {
2158            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
2159            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
2160            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
2161            other => Err(self.err(alloc::format!(
2162                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
2163            ))),
2164        }
2165    }
2166
2167    fn parse_sequence_options(
2168        &mut self,
2169        allow_restart: bool,
2170    ) -> Result<crate::ast::SequenceOptions, ParseError> {
2171        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
2172        let mut opts = SequenceOptions::default();
2173        #[allow(clippy::while_let_loop)]
2174        loop {
2175            // Match an ident; stop at any non-ident token (sentinel,
2176            // semicolon, end of statement).
2177            let kw_lc = match self.peek() {
2178                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
2179                _ => break,
2180            };
2181            match kw_lc.as_str() {
2182                "increment" => {
2183                    self.advance();
2184                    // Optional BY.
2185                    if matches!(self.peek(), Token::By) {
2186                        self.advance();
2187                    }
2188                    opts.increment = Some(self.expect_signed_int()?);
2189                }
2190                "minvalue" => {
2191                    self.advance();
2192                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
2193                }
2194                "maxvalue" => {
2195                    self.advance();
2196                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
2197                }
2198                "no" => {
2199                    self.advance();
2200                    let what = self.expect_ident_like()?;
2201                    match what.to_ascii_lowercase().as_str() {
2202                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
2203                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
2204                        "cycle" => opts.cycle = Some(false),
2205                        other => {
2206                            return Err(self.err(alloc::format!(
2207                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
2208                            )));
2209                        }
2210                    }
2211                }
2212                "start" => {
2213                    self.advance();
2214                    // Optional WITH.
2215                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2216                        if s.eq_ignore_ascii_case("with"))
2217                    {
2218                        self.advance();
2219                    }
2220                    opts.start = Some(self.expect_signed_int()?);
2221                }
2222                "restart" if allow_restart => {
2223                    self.advance();
2224                    // Optional WITH n; bare RESTART means restart at START.
2225                    let mut with_val: Option<i64> = None;
2226                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2227                        if s.eq_ignore_ascii_case("with"))
2228                    {
2229                        self.advance();
2230                        with_val = Some(self.expect_signed_int()?);
2231                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
2232                        with_val = Some(self.expect_signed_int()?);
2233                    }
2234                    opts.restart = Some(with_val);
2235                }
2236                "cache" => {
2237                    self.advance();
2238                    opts.cache = Some(self.expect_signed_int()?);
2239                }
2240                "cycle" => {
2241                    self.advance();
2242                    opts.cycle = Some(true);
2243                }
2244                "owned" => {
2245                    self.advance();
2246                    // BY is a reserved Token::By; accept either form.
2247                    match self.peek() {
2248                        Token::By => {
2249                            self.advance();
2250                        }
2251                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
2252                            self.advance();
2253                        }
2254                        other => {
2255                            return Err(
2256                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
2257                            );
2258                        }
2259                    }
2260                    // OWNED BY {NONE | tab.col}. Read just one ident
2261                    // (NOT expect_ident_like which would auto-strip
2262                    // a schema prefix and consume the `.col` we need).
2263                    let first = match self.advance() {
2264                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2265                        other => {
2266                            return Err(self.err(alloc::format!(
2267                                "expected identifier or NONE after OWNED BY, got {other:?}"
2268                            )));
2269                        }
2270                    };
2271                    if first.eq_ignore_ascii_case("none") {
2272                        opts.owned_by = Some(SequenceOwnedBy::None);
2273                    } else if matches!(self.peek(), Token::Dot) {
2274                        self.advance();
2275                        let second = match self.advance() {
2276                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2277                            other => {
2278                                return Err(self.err(alloc::format!(
2279                                    "expected column name after OWNED BY {first}., got {other:?}"
2280                                )));
2281                            }
2282                        };
2283                        // v7.17 dump-compat fix — pg_dump emits
2284                        // OWNED BY clauses as
2285                        // `schema.table.column` (three segments).
2286                        // If a third `.<ident>` follows, treat the
2287                        // first ident as schema (drop it; SPG is
2288                        // single-schema) and the middle / last
2289                        // pair as table.column. Otherwise it's
2290                        // the two-segment form table.column.
2291                        if matches!(self.peek(), Token::Dot) {
2292                            self.advance();
2293                            let third = match self.advance() {
2294                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2295                                other => {
2296                                    return Err(self.err(alloc::format!(
2297                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2298                                    )));
2299                                }
2300                            };
2301                            let _ = first; // schema prefix discarded
2302                            opts.owned_by = Some(SequenceOwnedBy::Column {
2303                                table: second,
2304                                column: third,
2305                            });
2306                        } else {
2307                            opts.owned_by = Some(SequenceOwnedBy::Column {
2308                                table: first,
2309                                column: second,
2310                            });
2311                        }
2312                    } else {
2313                        return Err(self.err(alloc::format!(
2314                            "expected table.column or NONE after OWNED BY, got {first:?}"
2315                        )));
2316                    }
2317                }
2318                _ => break,
2319            }
2320        }
2321        Ok(opts)
2322    }
2323
2324    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2325        let neg = if matches!(self.peek(), Token::Minus) {
2326            self.advance();
2327            true
2328        } else {
2329            false
2330        };
2331        match self.peek() {
2332            Token::Integer(n) => {
2333                let v = *n;
2334                self.advance();
2335                Ok(if neg { -v } else { v })
2336            }
2337            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2338        }
2339    }
2340
2341    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2342    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2343    /// clause is fully accepted and discarded — SPG always runs
2344    /// constraint checks immediately (single-writer model). The
2345    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2346    /// in either order (per the SQL spec they're independent),
2347    /// though pg_dump always emits them in the canonical
2348    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2349    /// Stops at the first token that isn't part of the clause.
2350    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2351        loop {
2352            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2353            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2354                self.advance();
2355                self.consume_optional_initially_clause()?;
2356                continue;
2357            }
2358            // `NOT DEFERRABLE` — already worked pre-3.1.
2359            if matches!(self.peek(), Token::Not) {
2360                let look = self.tokens.get(self.pos + 1);
2361                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2362                    self.advance(); // NOT
2363                    self.advance(); // DEFERRABLE
2364                    self.consume_optional_initially_clause()?;
2365                    continue;
2366                }
2367                break;
2368            }
2369            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2370            // accepts this without a leading [NOT] DEFERRABLE
2371            // (the timing keyword alone). pg_dump occasionally
2372            // emits it on FK constraints that inherit timing.
2373            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2374                self.consume_optional_initially_clause()?;
2375                continue;
2376            }
2377            break;
2378        }
2379        Ok(())
2380    }
2381
2382    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2383    /// next token is `INITIALLY`, consume it plus the required
2384    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2385    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2386        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2387            return Ok(());
2388        }
2389        self.advance(); // INITIALLY
2390        match self.advance() {
2391            Token::Ident(s)
2392                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2393            {
2394                Ok(())
2395            }
2396            other => Err(self.err(alloc::format!(
2397                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2398            ))),
2399        }
2400    }
2401
2402    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2403    /// in its entirety so the parser returns Empty without
2404    /// touching the runtime. The CREATE+PROCEDURE keywords are
2405    /// already consumed; this swallows everything from the
2406    /// procedure name through the matching `END`, including
2407    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2408    /// (DELIMITER `//` makes the script splitter forward the
2409    /// whole block as one statement), `@var` session-variable
2410    /// references, and the trailing terminator.
2411    ///
2412    /// Tracks nesting depth so:
2413    ///   BEGIN
2414    ///     IF cond THEN
2415    ///       BEGIN ... END;
2416    ///     END IF;
2417    ///   END
2418    /// terminates at the outer END.
2419    fn consume_mysql_routine_body(&mut self) {
2420        // Outer skeleton: name, (...), optional clauses, BEGIN
2421        // <body> END [;]. Scan for the first BEGIN — anything
2422        // before it is signature decoration we don't care about.
2423        // Once inside BEGIN, count up on BEGIN, down on END.
2424        let mut depth: i32 = 0;
2425        let mut started = false;
2426        loop {
2427            match self.peek().clone() {
2428                Token::Begin => {
2429                    self.advance();
2430                    depth += 1;
2431                    started = true;
2432                }
2433                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2434                    self.advance();
2435                    if started {
2436                        depth -= 1;
2437                        if depth <= 0 {
2438                            // Optional trailing ident (`END IF`,
2439                            // `END LOOP`, `END WHILE`, `END CASE`,
2440                            // `END label_name`) — eat the next
2441                            // ident if present so we don't
2442                            // mistake `END IF;` for the outer
2443                            // close.
2444                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2445                                // If the next token is one of the
2446                                // PL/SQL block-closer keywords,
2447                                // the END belongs to an inner
2448                                // block; bump depth back up.
2449                                let is_inner_close = matches!(
2450                                    self.peek(),
2451                                    Token::Ident(s) | Token::QuotedIdent(s)
2452                                        if matches!(
2453                                            s.to_ascii_lowercase().as_str(),
2454                                            "if" | "loop" | "while" | "case" | "repeat"
2455                                        )
2456                                );
2457                                if is_inner_close {
2458                                    self.advance();
2459                                    depth += 1;
2460                                    continue;
2461                                }
2462                            }
2463                            // Eat optional trailing `;`.
2464                            if matches!(self.peek(), Token::Semicolon) {
2465                                self.advance();
2466                            }
2467                            return;
2468                        }
2469                    }
2470                }
2471                Token::Eof => return,
2472                _ => {
2473                    self.advance();
2474                }
2475            }
2476        }
2477    }
2478
2479    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2480    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2481    ///
2482    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2483    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2484    ///   ident, or `ident @ ident-or-quoted-string` host form)
2485    /// * `SQL SECURITY {DEFINER|INVOKER}`
2486    ///
2487    /// Each clause may appear at most once but in any order.
2488    /// The hints are pure planner / permission metadata that
2489    /// SPG's view-rewrite engine handles uniformly; we accept
2490    /// and discard. Returns `Ok(())` once a non-clause token is
2491    /// peeked (the caller then checks for the `VIEW` keyword).
2492    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2493        loop {
2494            match self.peek().clone() {
2495                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2496                    self.advance(); // ALGORITHM
2497                    // Optional `=`. MySQL spec requires it but be
2498                    // generous.
2499                    if matches!(self.peek(), Token::Eq) {
2500                        self.advance();
2501                    }
2502                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2503                    // bare ident; unknown values still parse so
2504                    // future MySQL versions don't break.
2505                    if matches!(
2506                        self.peek(),
2507                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2508                    ) {
2509                        self.advance();
2510                    }
2511                }
2512                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2513                    self.advance(); // DEFINER
2514                    if matches!(self.peek(), Token::Eq) {
2515                        self.advance();
2516                    }
2517                    // User: quoted string, ident, OR ident @ host
2518                    // (host may itself be quoted or bare).
2519                    match self.peek().clone() {
2520                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2521                            self.advance();
2522                            // Optional `@host`.
2523                            if matches!(self.peek(), Token::At) {
2524                                self.advance();
2525                                if matches!(
2526                                    self.peek(),
2527                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2528                                ) {
2529                                    self.advance();
2530                                }
2531                            }
2532                        }
2533                        _ => {}
2534                    }
2535                }
2536                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2537                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2538                    // when followed by SECURITY — the dispatcher must
2539                    // not consume a bare `SQL` token (it's not a
2540                    // legal CREATE prefix on its own).
2541                    let save = self.pos;
2542                    self.advance(); // SQL
2543                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2544                        if s2.eq_ignore_ascii_case("security"))
2545                    {
2546                        self.advance(); // SECURITY
2547                        // DEFINER / INVOKER trailing ident.
2548                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2549                            self.advance();
2550                        }
2551                    } else {
2552                        // Not a SQL SECURITY clause — roll back and
2553                        // bail; the caller will error out cleanly.
2554                        self.pos = save;
2555                        return Ok(());
2556                    }
2557                }
2558                _ => return Ok(()),
2559            }
2560        }
2561    }
2562
2563    fn parse_if_not_exists(&mut self) -> bool {
2564        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2565        {
2566            let save = self.pos;
2567            self.advance();
2568            if matches!(self.peek(), Token::Not) {
2569                self.advance();
2570                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2571                {
2572                    self.advance();
2573                    return true;
2574                }
2575            }
2576            self.pos = save;
2577        }
2578        false
2579    }
2580
2581    fn parse_if_exists(&mut self) -> bool {
2582        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2583        {
2584            let save = self.pos;
2585            self.advance();
2586            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2587            {
2588                self.advance();
2589                return true;
2590            }
2591            self.pos = save;
2592        }
2593        false
2594    }
2595
2596    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2597    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2598    /// been consumed.
2599    fn parse_create_trigger_after_keyword(
2600        &mut self,
2601        or_replace: bool,
2602    ) -> Result<Statement, ParseError> {
2603        let name = self.expect_ident_like()?;
2604        let timing = {
2605            let ident = self.expect_ident_like()?;
2606            if ident.eq_ignore_ascii_case("before") {
2607                TriggerTiming::Before
2608            } else if ident.eq_ignore_ascii_case("after") {
2609                TriggerTiming::After
2610            } else if ident.eq_ignore_ascii_case("instead") {
2611                let next = self.expect_ident_like()?;
2612                if !next.eq_ignore_ascii_case("of") {
2613                    return Err(self.err(alloc::format!(
2614                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2615                    )));
2616                }
2617                TriggerTiming::InsteadOf
2618            } else {
2619                return Err(self.err(alloc::format!(
2620                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2621                )));
2622            }
2623        };
2624        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2625        // OR is a reserved keyword token (Token::Or), not an Ident.
2626        // v7.13.0 — after an UPDATE event we may optionally see
2627        // `OF col, col, …` (mailrs round-5 G7). Columns are
2628        // captured into `update_columns` once across the whole
2629        // events list; multiple `UPDATE OF` clauses are rejected.
2630        let mut events: Vec<TriggerEvent> = Vec::new();
2631        let mut update_columns: Vec<String> = Vec::new();
2632        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2633        events.push(first_ev);
2634        if !first_cols.is_empty() {
2635            update_columns = first_cols;
2636        }
2637        while matches!(self.peek(), Token::Or) {
2638            self.advance();
2639            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2640            events.push(ev);
2641            if !cols.is_empty() {
2642                if !update_columns.is_empty() {
2643                    return Err(
2644                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2645                    );
2646                }
2647                update_columns = cols;
2648            }
2649        }
2650        // ON <table>
2651        let tok = self.peek();
2652        let Token::On = tok else {
2653            return Err(self.err(alloc::format!(
2654                "expected ON after trigger events, got {tok:?}"
2655            )));
2656        };
2657        self.advance();
2658        let table = self.expect_ident_like()?;
2659        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2660        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2661        // idents.
2662        if !matches!(self.peek(), Token::For) {
2663            return Err(self.err(alloc::format!(
2664                "expected FOR EACH ROW / STATEMENT, got {:?}",
2665                self.peek()
2666            )));
2667        }
2668        self.advance();
2669        let for_each = {
2670            let e = self.expect_ident_like()?;
2671            if !e.eq_ignore_ascii_case("each") {
2672                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2673            }
2674            let unit = self.expect_ident_like()?;
2675            if unit.eq_ignore_ascii_case("row") {
2676                TriggerForEach::Row
2677            } else if unit.eq_ignore_ascii_case("statement") {
2678                TriggerForEach::Statement
2679            } else {
2680                return Err(self.err(alloc::format!(
2681                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2682                )));
2683            }
2684        };
2685        // EXECUTE FUNCTION/PROCEDURE name(...)
2686        let exec = self.expect_ident_like()?;
2687        if !exec.eq_ignore_ascii_case("execute") {
2688            return Err(self.err(alloc::format!(
2689                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2690            )));
2691        }
2692        let fn_or_proc = self.expect_ident_like()?;
2693        if !(fn_or_proc.eq_ignore_ascii_case("function")
2694            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2695        {
2696            return Err(self.err(alloc::format!(
2697                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2698            )));
2699        }
2700        let function = self.expect_ident_like()?;
2701        // Optional empty arg list `()`.
2702        if matches!(self.peek(), Token::LParen) {
2703            self.advance();
2704            if !matches!(self.peek(), Token::RParen) {
2705                return Err(self.err(alloc::format!(
2706                    "v7.12.4 trigger function calls take no args; got {:?}",
2707                    self.peek()
2708                )));
2709            }
2710            self.advance();
2711        }
2712        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2713            name,
2714            or_replace,
2715            timing,
2716            events,
2717            table,
2718            for_each,
2719            function,
2720            update_columns,
2721        }))
2722    }
2723
2724    /// v7.13.0 — parse one trigger event, then optionally consume
2725    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2726    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2727    fn parse_trigger_event_with_optional_of(
2728        &mut self,
2729    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2730        let ev = self.parse_trigger_event()?;
2731        if !matches!(ev, TriggerEvent::Update) {
2732            return Ok((ev, Vec::new()));
2733        }
2734        // `OF` is a bare ident.
2735        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2736            return Ok((ev, Vec::new()));
2737        }
2738        self.advance(); // OF
2739        let mut cols: Vec<String> = Vec::new();
2740        loop {
2741            cols.push(self.expect_ident_like()?);
2742            if matches!(self.peek(), Token::Comma) {
2743                self.advance();
2744                continue;
2745            }
2746            break;
2747        }
2748        if cols.is_empty() {
2749            return Err(
2750                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2751            );
2752        }
2753        Ok((ev, cols))
2754    }
2755
2756    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2757    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2758    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2759    /// inside the body.
2760    /// Called by [`parse_plpgsql_body`] after the body's tokens
2761    /// have been lexed into this temporary parser.
2762    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2763        // v7.12.6 — optional DECLARE prelude.
2764        let declarations = if matches!(
2765            self.peek(),
2766            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2767        ) {
2768            self.advance();
2769            self.parse_plpgsql_declare_block()?
2770        } else {
2771            Vec::new()
2772        };
2773        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2774        // `BEGIN` transaction-start, but we can reuse the
2775        // reserved Token::Begin since the body is a separate
2776        // lex/parse context).
2777        if !matches!(self.peek(), Token::Begin) {
2778            return Err(self.err(alloc::format!(
2779                "expected BEGIN at start of plpgsql block, got {:?}",
2780                self.peek()
2781            )));
2782        }
2783        self.advance();
2784        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2785        Ok(PlPgSqlBlock {
2786            declarations,
2787            statements,
2788        })
2789    }
2790
2791    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2792    /// prelude. Caller has already consumed `DECLARE`. We stop
2793    /// reading entries when we hit `BEGIN`.
2794    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2795        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2796        loop {
2797            if matches!(self.peek(), Token::Begin) {
2798                return Ok(out);
2799            }
2800            let name = self.expect_ident_like()?;
2801            let ty_token = self.expect_ident_like()?;
2802            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2803                Some(t) => FunctionArgType::Typed(t),
2804                None => FunctionArgType::Raw(ty_token),
2805            };
2806            let default = match self.peek() {
2807                Token::ColonEq => {
2808                    self.advance();
2809                    Some(self.parse_expr(0)?)
2810                }
2811                Token::Eq => {
2812                    // PL/pgSQL also accepts `=` for the
2813                    // DECLARE default (PG treats them the same
2814                    // in this position).
2815                    self.advance();
2816                    Some(self.parse_expr(0)?)
2817                }
2818                _ => None,
2819            };
2820            // Mandatory `;` between declarations.
2821            if !matches!(self.peek(), Token::Semicolon) {
2822                return Err(self.err(alloc::format!(
2823                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2824                    self.peek()
2825                )));
2826            }
2827            self.advance();
2828            out.push(PlPgSqlDeclare { name, ty, default });
2829        }
2830    }
2831
2832    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2833    /// the terminating `END;` (or `END IF;` etc — handled by the
2834    /// per-construct sub-parsers). Used by both the outer block
2835    /// and the IF/ELSE branch bodies.
2836    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2837        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2838        loop {
2839            // Allow trailing semicolons + END.
2840            while matches!(self.peek(), Token::Semicolon) {
2841                self.advance();
2842            }
2843            // END / ELSE / ELSIF — handled by the caller.
2844            if matches!(
2845                self.peek(),
2846                Token::Ident(s) | Token::QuotedIdent(s)
2847                    if s.eq_ignore_ascii_case("end")
2848                        || s.eq_ignore_ascii_case("else")
2849                        || s.eq_ignore_ascii_case("elsif")
2850                        || s.eq_ignore_ascii_case("elseif")
2851            ) {
2852                return Ok(statements);
2853            }
2854            // Otherwise: one statement, then expect `;` or
2855            // a block-terminator keyword.
2856            let stmt = self.parse_plpgsql_stmt()?;
2857            statements.push(stmt);
2858            match self.peek() {
2859                Token::Semicolon => {
2860                    self.advance();
2861                }
2862                Token::Ident(s) | Token::QuotedIdent(s)
2863                    if s.eq_ignore_ascii_case("end")
2864                        || s.eq_ignore_ascii_case("else")
2865                        || s.eq_ignore_ascii_case("elsif")
2866                        || s.eq_ignore_ascii_case("elseif") =>
2867                {
2868                    // Final statement of the block without `;`.
2869                }
2870                other => {
2871                    return Err(self.err(alloc::format!(
2872                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2873                    )));
2874                }
2875            }
2876        }
2877    }
2878
2879    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2880        // RETURN keyword?
2881        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2882        {
2883            self.advance();
2884            return self.parse_plpgsql_return();
2885        }
2886        // v7.12.6 — IF block.
2887        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2888        {
2889            self.advance();
2890            return self.parse_plpgsql_if();
2891        }
2892        // v7.12.6 — RAISE.
2893        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2894        {
2895            self.advance();
2896            return self.parse_plpgsql_raise();
2897        }
2898        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2899        // plpgsql-specific shape (mailrs round-10 migrate-042).
2900        // PG's SELECT INTO at top-level SQL would CREATE a new
2901        // table; inside plpgsql it ASSIGNS the query result to
2902        // a local variable. We detect the INTO at paren-depth
2903        // 0 between SELECT and the statement boundary; if
2904        // found, split the token stream into "pre-INTO
2905        // projection" + "var" + "post-INTO FROM/WHERE…" and
2906        // rebuild as a SelectInto with a regular SELECT body
2907        // (no INTO clause).
2908        if matches!(self.peek(), Token::Select)
2909            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2910        {
2911            return Ok(PlPgSqlStmt::SelectInto {
2912                var: var_name,
2913                body: Box::new(select_body),
2914            });
2915        }
2916        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2917        // SELECT can appear directly inside a trigger body; we
2918        // recurse into the regular Statement parser, which will
2919        // stop at the trailing `;` (which our caller then
2920        // consumes).
2921        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2922        // also embed ALTER / CREATE / DROP statements; route
2923        // those through the same parser so the DO body parses
2924        // cleanly.
2925        if matches!(self.peek(), Token::Insert)
2926            || matches!(self.peek(), Token::Select)
2927            || matches!(self.peek(), Token::Create)
2928            || matches!(self.peek(), Token::Drop)
2929            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2930                if s.eq_ignore_ascii_case("update")
2931                    || s.eq_ignore_ascii_case("delete")
2932                    || s.eq_ignore_ascii_case("alter"))
2933        {
2934            let stmt = self.parse_one_statement()?;
2935            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2936        }
2937        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
2938        // followed by `:=` and an expression.
2939        let target = self.parse_plpgsql_assign_target()?;
2940        // PL/pgSQL assignment uses `:=`. The lexer represents
2941        // this as a colon followed by `=`; check both shapes.
2942        match self.peek() {
2943            Token::ColonEq => {
2944                self.advance();
2945            }
2946            Token::Colon => {
2947                self.advance();
2948                if !matches!(self.peek(), Token::Eq) {
2949                    return Err(self.err(alloc::format!(
2950                        "expected := after plpgsql assign target, got `:` then {:?}",
2951                        self.peek()
2952                    )));
2953                }
2954                self.advance();
2955            }
2956            other => {
2957                return Err(self.err(alloc::format!(
2958                    "expected := after plpgsql assign target, got {other:?}"
2959                )));
2960            }
2961        }
2962        let value = self.parse_expr(0)?;
2963        Ok(PlPgSqlStmt::Assign { target, value })
2964    }
2965
2966    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
2967    /// [ELSE body] END IF`. `IF` keyword already consumed.
2968    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2969        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
2970        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
2971        loop {
2972            // <expr> THEN
2973            let cond = self.parse_expr(0)?;
2974            let then_kw = self.expect_ident_like()?;
2975            if !then_kw.eq_ignore_ascii_case("then") {
2976                return Err(self.err(alloc::format!(
2977                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
2978                )));
2979            }
2980            let body = self.parse_plpgsql_stmt_list_until_end()?;
2981            branches.push((cond, body));
2982            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
2983            match self.peek() {
2984                Token::Ident(s) | Token::QuotedIdent(s)
2985                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
2986                {
2987                    self.advance();
2988                    continue;
2989                }
2990                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
2991                    self.advance();
2992                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
2993                    break;
2994                }
2995                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2996                    break;
2997                }
2998                other => {
2999                    return Err(self.err(alloc::format!(
3000                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
3001                    )));
3002                }
3003            }
3004        }
3005        // Expect `END IF` (the END keyword is the one we're
3006        // looking at right now).
3007        let end_kw = self.expect_ident_like()?;
3008        if !end_kw.eq_ignore_ascii_case("end") {
3009            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
3010        }
3011        let if_kw = self.expect_ident_like()?;
3012        if !if_kw.eq_ignore_ascii_case("if") {
3013            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
3014        }
3015        Ok(PlPgSqlStmt::If {
3016            branches,
3017            else_branch,
3018        })
3019    }
3020
3021    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
3022    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
3023    /// is already consumed.
3024    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3025        let lvl_ident = self.expect_ident_like()?;
3026        let level = match lvl_ident.to_ascii_lowercase().as_str() {
3027            "notice" => RaiseLevel::Notice,
3028            "warning" => RaiseLevel::Warning,
3029            "info" => RaiseLevel::Info,
3030            "log" => RaiseLevel::Log,
3031            "debug" => RaiseLevel::Debug,
3032            "exception" => RaiseLevel::Exception,
3033            other => {
3034                return Err(self.err(alloc::format!(
3035                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
3036                )));
3037            }
3038        };
3039        // Message: required for v7.12.6. PG accepts a bare
3040        // RAISE-rethrow form (no message), reserved for future
3041        // RAISE-no-args support.
3042        let Token::String(msg) = self.peek() else {
3043            return Err(self.err(alloc::format!(
3044                "expected RAISE message string, got {:?}",
3045                self.peek()
3046            )));
3047        };
3048        let message = msg.clone();
3049        self.advance();
3050        // Optional comma-separated args (PG `%` format substitution).
3051        let mut args: Vec<Expr> = Vec::new();
3052        while matches!(self.peek(), Token::Comma) {
3053            self.advance();
3054            args.push(self.parse_expr(0)?);
3055        }
3056        Ok(PlPgSqlStmt::Raise {
3057            level,
3058            message,
3059            args,
3060        })
3061    }
3062
3063    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
3064    /// <projection> INTO <var> [FROM …]` (mailrs round-10
3065    /// migrate-042). Returns `(rebuilt_select_without_into,
3066    /// var_name)` when the pattern matches; `None` for
3067    /// regular SELECTs (those go through the embedded-SQL
3068    /// path). Token-stream surgery so the rebuilt SELECT
3069    /// parses through the regular `parse_select_stmt`.
3070    #[allow(clippy::too_many_lines)]
3071    fn try_parse_plpgsql_select_into(
3072        &mut self,
3073    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
3074        // Scan forward from `self.pos + 1` (past Token::Select)
3075        // for Token::Into at paren-depth 0, stopping at the
3076        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
3077        // end the plpgsql statement.
3078        let start = self.pos;
3079        let mut into_pos: Option<usize> = None;
3080        let mut depth: i32 = 0;
3081        let mut i = start + 1;
3082        while i < self.tokens.len() {
3083            match &self.tokens[i] {
3084                Token::LParen => depth += 1,
3085                Token::RParen => depth -= 1,
3086                Token::Semicolon if depth == 0 => break,
3087                Token::Ident(s)
3088                    if depth == 0
3089                        && (s.eq_ignore_ascii_case("end")
3090                            || s.eq_ignore_ascii_case("else")
3091                            || s.eq_ignore_ascii_case("elsif")) =>
3092                {
3093                    break;
3094                }
3095                Token::Into if depth == 0 => {
3096                    into_pos = Some(i);
3097                    break;
3098                }
3099                _ => {}
3100            }
3101            i += 1;
3102        }
3103        let Some(into_at) = into_pos else {
3104            return Ok(None);
3105        };
3106        // The token immediately after INTO must be the target
3107        // var ident; anything else (e.g. INSERT INTO table)
3108        // ruled out by the depth-0 check above. Capture it.
3109        let var = match self.tokens.get(into_at + 1) {
3110            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
3111            other => {
3112                return Err(self.err(alloc::format!(
3113                    "expected variable name after SELECT … INTO, got {other:?}"
3114                )));
3115            }
3116        };
3117        // Find the end of the plpgsql SELECT INTO statement —
3118        // same boundary rules as the depth-0 scan above.
3119        let mut end = into_at + 2;
3120        let mut depth2: i32 = 0;
3121        while end < self.tokens.len() {
3122            match &self.tokens[end] {
3123                Token::LParen => depth2 += 1,
3124                Token::RParen => depth2 -= 1,
3125                Token::Semicolon if depth2 == 0 => break,
3126                Token::Ident(s)
3127                    if depth2 == 0
3128                        && (s.eq_ignore_ascii_case("end")
3129                            || s.eq_ignore_ascii_case("else")
3130                            || s.eq_ignore_ascii_case("elsif")) =>
3131                {
3132                    break;
3133                }
3134                _ => {}
3135            }
3136            end += 1;
3137        }
3138        // Rebuild a token stream that represents the SELECT
3139        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
3140        // post-var tokens up to statement end]. Run the
3141        // regular `parse_select_stmt` against it.
3142        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
3143        for j in start..into_at {
3144            rebuilt.push(self.tokens[j].clone());
3145        }
3146        for j in (into_at + 2)..end {
3147            rebuilt.push(self.tokens[j].clone());
3148        }
3149        rebuilt.push(Token::Eof);
3150        let saved_pos = self.pos;
3151        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
3152        self.pos = 0;
3153        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
3154        if !matches!(self.peek(), Token::Select) {
3155            self.tokens = saved_tokens;
3156            self.pos = saved_pos;
3157            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
3158        }
3159        let sel = self.parse_select_stmt();
3160        self.tokens = saved_tokens;
3161        self.pos = end;
3162        let sel = sel?;
3163        let Statement::Select(body) = sel else {
3164            return Err(self.err(alloc::format!(
3165                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
3166            )));
3167        };
3168        Ok(Some((body, var)))
3169    }
3170
3171    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
3172        // v7.16.1 — read the head token DIRECTLY rather than
3173        // via `expect_ident_like`. The v7.14.0 schema-qualifier
3174        // strip (`public.t` → `t`) inside `expect_ident_like`
3175        // greedily consumes any `ident . ident` pair, which
3176        // silently turned every `NEW.col := …` /
3177        // `OLD.col := …` plpgsql assignment into a Local("col")
3178        // assignment — the head "new"/"old" was eaten as if it
3179        // were a schema name and the Dot was consumed too, so
3180        // this function's own `peek() == Token::Dot` check
3181        // below never fired. Every BEFORE trigger that rewrote
3182        // a NEW cell was a silent no-op for two major releases
3183        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
3184        // gate failures were investigated as v7.16.1 backlog.
3185        let head = match self.advance() {
3186            Token::Ident(s) | Token::QuotedIdent(s) => s,
3187            other => {
3188                return Err(self.err(alloc::format!(
3189                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
3190                )));
3191            }
3192        };
3193        if matches!(self.peek(), Token::Dot) {
3194            self.advance();
3195            let col = self.expect_ident_like()?;
3196            if head.eq_ignore_ascii_case("new") {
3197                return Ok(AssignTarget::NewColumn(col));
3198            }
3199            if head.eq_ignore_ascii_case("old") {
3200                return Ok(AssignTarget::OldColumn(col));
3201            }
3202            return Err(self.err(alloc::format!(
3203                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
3204                 got {head:?}.<col>"
3205            )));
3206        }
3207        Ok(AssignTarget::Local(head))
3208    }
3209
3210    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3211        // RETURN NEW / OLD / NULL — bare-ident forms.
3212        match self.peek() {
3213            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
3214                self.advance();
3215                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
3216            }
3217            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
3218                self.advance();
3219                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
3220            }
3221            Token::Null => {
3222                self.advance();
3223                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3224            }
3225            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
3226            // per PL/pgSQL convention.
3227            Token::Semicolon => {
3228                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3229            }
3230            _ => {}
3231        }
3232        // Fall through: parse a full expression.
3233        let e = self.parse_expr(0)?;
3234        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
3235    }
3236
3237    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
3238        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
3239        // are ident-shaped (the parser keys off case-insensitive
3240        // match — same shape used by the top-level Update / Delete
3241        // dispatchers at parse_one_statement).
3242        if matches!(self.peek(), Token::Insert) {
3243            self.advance();
3244            return Ok(TriggerEvent::Insert);
3245        }
3246        match self.peek() {
3247            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3248                self.advance();
3249                Ok(TriggerEvent::Update)
3250            }
3251            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3252                self.advance();
3253                Ok(TriggerEvent::Delete)
3254            }
3255            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
3256                self.advance();
3257                Ok(TriggerEvent::Truncate)
3258            }
3259            other => Err(self.err(alloc::format!(
3260                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3261            ))),
3262        }
3263    }
3264
3265    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3266    ///   - (no clause) → implicit `FOR ALL TABLES`
3267    ///   - `FOR ALL TABLES`
3268    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3269    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3270    ///     accepted (PG accepts both forms in PG 19).
3271    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3272        let name = self.expect_ident_or_string()?;
3273        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3274        // shape so existing publications keep parsing identically.
3275        let scope = if matches!(self.peek(), Token::For) {
3276            self.advance();
3277            if matches!(self.peek(), Token::All) {
3278                self.advance();
3279                if !matches!(self.peek(), Token::Tables) {
3280                    return Err(self.err(format!(
3281                        "expected TABLES after FOR ALL, got {:?}",
3282                        self.peek()
3283                    )));
3284                }
3285                self.advance();
3286                if matches!(self.peek(), Token::Except) {
3287                    self.advance();
3288                    let tables = self.parse_publication_table_list()?;
3289                    PublicationScope::AllTablesExcept(tables)
3290                } else {
3291                    PublicationScope::AllTables
3292                }
3293            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3294                // PG 19 accepts both `FOR TABLE …` (singular) and
3295                // `FOR TABLES …` (plural); SPG matches.
3296                self.advance();
3297                let tables = self.parse_publication_table_list()?;
3298                PublicationScope::ForTables(tables)
3299            } else {
3300                return Err(self.err(format!(
3301                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3302                    self.peek()
3303                )));
3304            }
3305        } else {
3306            PublicationScope::AllTables
3307        };
3308        Ok(Statement::CreatePublication(CreatePublicationStatement {
3309            name,
3310            scope,
3311        }))
3312    }
3313
3314    /// v6.1.3 — Comma-separated identifier list for the publication
3315    /// FOR-clause. Requires at least one entry; empty list is a
3316    /// parse error (PG behaviour). Quoted idents are accepted; the
3317    /// names round-trip through `Display` as `quote_ident(name)`.
3318    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3319        let first = self.expect_ident_like()?;
3320        let mut out = alloc::vec![first];
3321        while matches!(self.peek(), Token::Comma) {
3322            self.advance();
3323            out.push(self.expect_ident_like()?);
3324        }
3325        Ok(out)
3326    }
3327
3328    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3329    ///                 CONNECTION '<conn>'
3330    ///                 PUBLICATION <pub> [, <pub> ...]`.
3331    ///
3332    /// The clause order is fixed (CONNECTION first, then
3333    /// PUBLICATION) to match PG. No WITH-options accepted in
3334    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3335    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3336        let name = self.expect_ident_or_string()?;
3337        if !matches!(self.peek(), Token::Connection) {
3338            return Err(self.err(format!(
3339                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3340                self.peek()
3341            )));
3342        }
3343        self.advance();
3344        let conn_str = self.expect_string_literal()?;
3345        if !matches!(self.peek(), Token::Publication) {
3346            return Err(self.err(format!(
3347                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3348                self.peek()
3349            )));
3350        }
3351        self.advance();
3352        // Reuse the publication FOR-list parser shape: at least one
3353        // identifier, comma-separated.
3354        let first = self.expect_ident_like()?;
3355        let mut publications = alloc::vec![first];
3356        while matches!(self.peek(), Token::Comma) {
3357            self.advance();
3358            publications.push(self.expect_ident_like()?);
3359        }
3360        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3361            name,
3362            conn_str,
3363            publications,
3364        }))
3365    }
3366
3367    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3368    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3369    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3370    /// that fit `u64`.
3371    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3372    /// (`pg_catalog.default_text_search_config` etc).
3373    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3374        let mut name = self.expect_ident_like()?;
3375        while matches!(self.peek(), Token::Dot) {
3376            self.advance();
3377            let next = self.expect_ident_like()?;
3378            name.push('.');
3379            name.push_str(&next);
3380        }
3381        Ok(name.to_ascii_lowercase())
3382    }
3383
3384    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3385        match self.advance() {
3386            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3387            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3388                Ok(crate::ast::SetValue::Default)
3389            }
3390            Token::Ident(s) | Token::QuotedIdent(s) => {
3391                let mut accum = s;
3392                while matches!(self.peek(), Token::Dot) {
3393                    self.advance();
3394                    let next = self.expect_ident_like()?;
3395                    accum.push('.');
3396                    accum.push_str(&next);
3397                }
3398                Ok(crate::ast::SetValue::Ident(accum))
3399            }
3400            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3401            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3402            // v7.22 (mailrs round-13 gap 2) — PG boolean parameter
3403            // spellings that lex as keyword tokens, not idents:
3404            // `SET standard_conforming_strings = on` is in every
3405            // pg_dump preamble (`off` already lexes as an ident).
3406            Token::On => Ok(crate::ast::SetValue::Ident("on".to_string())),
3407            Token::True => Ok(crate::ast::SetValue::Ident("true".to_string())),
3408            Token::False => Ok(crate::ast::SetValue::Ident("false".to_string())),
3409            // v7.14.0 — MySQL session/user variable RHS
3410            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3411            // Wrap as Ident so the SET handler can record it; the
3412            // engine treats `@VAR` / `@@VAR` values as opaque
3413            // strings.
3414            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3415            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3416            // is the common MySQL preamble shape. Allow a `+` or
3417            // `-` prefix on negative numerics for parity with PG
3418            // (some param defaults are negative).
3419            Token::Minus => match self.advance() {
3420                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3421                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3422                other => Err(self.err(format!(
3423                    "expected numeric after `-` in SET value, got {other:?}"
3424                ))),
3425            },
3426            other => Err(self.err(format!(
3427                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3428            ))),
3429        }
3430    }
3431
3432    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3433        // FOR is a v6.1.2-reserved keyword (Token::For). The
3434        // other two are bare idents — they've never needed lexer
3435        // support and we keep it that way.
3436        if !matches!(self.peek(), Token::For) {
3437            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3438        }
3439        self.advance();
3440        self.expect_keyword_ident("wal")?;
3441        self.expect_keyword_ident("position")?;
3442        let pos = self.expect_u64_literal()?;
3443        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3444        {
3445            self.advance();
3446            self.expect_keyword_ident("timeout")?;
3447            Some(self.expect_u64_literal()?)
3448        } else {
3449            None
3450        };
3451        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3452    }
3453
3454    /// v6.1.7 helper — consume a `Token::Integer` and check it
3455    /// fits `u64`. WAL positions and millisecond timeouts are
3456    /// non-negative.
3457    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3458        match self.advance() {
3459            Token::Integer(n) if n >= 0 => Ok(n as u64),
3460            Token::Integer(n) => Err(ParseError {
3461                message: format!("expected non-negative integer, got {n}"),
3462                token_pos: self.pos.saturating_sub(1),
3463            }),
3464            other => Err(ParseError {
3465                message: format!("expected integer literal, got {other:?}"),
3466                token_pos: self.pos.saturating_sub(1),
3467            }),
3468        }
3469    }
3470
3471    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3472    /// ROLE '<role>' (defaults to readonly). All string slots accept
3473    /// either a quoted ident or a quoted string literal.
3474    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3475        let name = self.expect_ident_or_string()?;
3476        self.expect_keyword_ident("with")?;
3477        self.expect_keyword_ident("password")?;
3478        let password = self.expect_string_literal()?;
3479        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3480            && s.eq_ignore_ascii_case("role")
3481        {
3482            self.advance();
3483            self.expect_string_literal()?
3484        } else {
3485            "readonly".to_string()
3486        };
3487        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3488            name,
3489            password,
3490            role,
3491        }))
3492    }
3493
3494    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3495    /// Caller already consumed the leading `UPDATE` ident.
3496    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3497        let table = self.expect_ident_like()?;
3498        self.expect_keyword_ident("set")?;
3499        let mut assignments = Vec::new();
3500        loop {
3501            let col = self.expect_ident_like()?;
3502            if !matches!(self.peek(), Token::Eq) {
3503                return Err(self.err(format!(
3504                    "expected `=` after column name in UPDATE SET, got {:?}",
3505                    self.peek()
3506                )));
3507            }
3508            self.advance();
3509            let value = self.parse_expr(0)?;
3510            assignments.push((col, value));
3511            if matches!(self.peek(), Token::Comma) {
3512                self.advance();
3513                continue;
3514            }
3515            break;
3516        }
3517        let where_ = if matches!(self.peek(), Token::Where) {
3518            self.advance();
3519            Some(self.parse_expr(0)?)
3520        } else {
3521            None
3522        };
3523        let returning = self.parse_optional_returning()?;
3524        Ok(Statement::Update(crate::ast::UpdateStatement {
3525            ctes: Vec::new(),
3526            table,
3527            assignments,
3528            where_,
3529            returning,
3530        }))
3531    }
3532
3533    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3534    /// the leading `DELETE` ident.
3535    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3536        if !matches!(self.peek(), Token::From) {
3537            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3538        }
3539        self.advance();
3540        let table = self.expect_ident_like()?;
3541        let where_ = if matches!(self.peek(), Token::Where) {
3542            self.advance();
3543            Some(self.parse_expr(0)?)
3544        } else {
3545            None
3546        };
3547        let returning = self.parse_optional_returning()?;
3548        Ok(Statement::Delete(crate::ast::DeleteStatement {
3549            ctes: Vec::new(),
3550            table,
3551            where_,
3552            returning,
3553        }))
3554    }
3555
3556    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3557    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3558    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3559    /// keyword. v7.17 surface:
3560    ///   * source: table reference (subquery source is a follow-up)
3561    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3562    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3563    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3564    ///     order
3565    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3566        // INTO
3567        let is_into_kw = matches!(self.peek(), Token::Into)
3568            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3569        if !is_into_kw {
3570            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3571        }
3572        self.advance();
3573        let target = self.expect_ident_like()?;
3574        // Optional alias — bare ident before USING.
3575        let target_alias = match self.peek() {
3576            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3577                Some(self.expect_ident_like()?)
3578            }
3579            _ => None,
3580        };
3581        // USING
3582        let is_using_kw = matches!(
3583            self.peek(),
3584            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3585        );
3586        if !is_using_kw {
3587            return Err(self.err(format!(
3588                "expected USING after MERGE INTO target, got {:?}",
3589                self.peek()
3590            )));
3591        }
3592        self.advance();
3593        let source = self.expect_ident_like()?;
3594        let source_alias = match self.peek() {
3595            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3596                Some(self.expect_ident_like()?)
3597            }
3598            _ => None,
3599        };
3600        // ON
3601        if !matches!(self.peek(), Token::On) {
3602            return Err(self.err(format!(
3603                "expected ON after MERGE … USING source, got {:?}",
3604                self.peek()
3605            )));
3606        }
3607        self.advance();
3608        let on = self.parse_expr(0)?;
3609        // One or more WHEN clauses.
3610        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3611        loop {
3612            let is_when_kw = matches!(
3613                self.peek(),
3614                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3615            );
3616            if !is_when_kw {
3617                break;
3618            }
3619            self.advance(); // WHEN
3620            // [NOT] MATCHED
3621            let matched = if matches!(self.peek(), Token::Not) {
3622                self.advance();
3623                crate::ast::MergeMatched::NotMatched
3624            } else {
3625                crate::ast::MergeMatched::Matched
3626            };
3627            let is_matched_kw = matches!(
3628                self.peek(),
3629                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3630            );
3631            if !is_matched_kw {
3632                return Err(self.err(format!(
3633                    "expected MATCHED in WHEN clause, got {:?}",
3634                    self.peek()
3635                )));
3636            }
3637            self.advance();
3638            // Optional AND <expr>
3639            let condition = if matches!(self.peek(), Token::And) {
3640                self.advance();
3641                Some(self.parse_expr(0)?)
3642            } else {
3643                None
3644            };
3645            // THEN
3646            let is_then_kw = matches!(
3647                self.peek(),
3648                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3649            );
3650            if !is_then_kw {
3651                return Err(self.err(format!(
3652                    "expected THEN in WHEN clause, got {:?}",
3653                    self.peek()
3654                )));
3655            }
3656            self.advance();
3657            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3658            let action = match self.peek().clone() {
3659                Token::Insert => {
3660                    self.advance();
3661                    // (cols)
3662                    if !matches!(self.peek(), Token::LParen) {
3663                        return Err(self.err(format!(
3664                            "expected '(' after INSERT in MERGE, got {:?}",
3665                            self.peek()
3666                        )));
3667                    }
3668                    self.advance();
3669                    let mut columns: Vec<String> = Vec::new();
3670                    loop {
3671                        columns.push(self.expect_ident_like()?);
3672                        if matches!(self.peek(), Token::Comma) {
3673                            self.advance();
3674                            continue;
3675                        }
3676                        break;
3677                    }
3678                    if !matches!(self.peek(), Token::RParen) {
3679                        return Err(self.err(format!(
3680                            "expected ')' after INSERT column list, got {:?}",
3681                            self.peek()
3682                        )));
3683                    }
3684                    self.advance();
3685                    // VALUES (...)
3686                    if !matches!(self.peek(), Token::Values) {
3687                        return Err(self.err(format!(
3688                            "expected VALUES in MERGE INSERT, got {:?}",
3689                            self.peek()
3690                        )));
3691                    }
3692                    self.advance();
3693                    if !matches!(self.peek(), Token::LParen) {
3694                        return Err(self.err(format!(
3695                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3696                            self.peek()
3697                        )));
3698                    }
3699                    self.advance();
3700                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3701                    loop {
3702                        values.push(self.parse_expr(0)?);
3703                        if matches!(self.peek(), Token::Comma) {
3704                            self.advance();
3705                            continue;
3706                        }
3707                        break;
3708                    }
3709                    if !matches!(self.peek(), Token::RParen) {
3710                        return Err(self.err(format!(
3711                            "expected ')' after MERGE INSERT values, got {:?}",
3712                            self.peek()
3713                        )));
3714                    }
3715                    self.advance();
3716                    if columns.len() != values.len() {
3717                        return Err(self.err(format!(
3718                            "MERGE INSERT column count ({}) ≠ value count ({})",
3719                            columns.len(),
3720                            values.len()
3721                        )));
3722                    }
3723                    crate::ast::MergeAction::Insert { columns, values }
3724                }
3725                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3726                    self.advance();
3727                    // SET
3728                    let is_set_kw = matches!(
3729                        self.peek(),
3730                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3731                    );
3732                    if !is_set_kw {
3733                        return Err(self.err(format!(
3734                            "expected SET after UPDATE in MERGE, got {:?}",
3735                            self.peek()
3736                        )));
3737                    }
3738                    self.advance();
3739                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3740                    loop {
3741                        let col = self.expect_ident_like()?;
3742                        if !matches!(self.peek(), Token::Eq) {
3743                            return Err(self.err(format!(
3744                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3745                                self.peek()
3746                            )));
3747                        }
3748                        self.advance();
3749                        let expr = self.parse_expr(0)?;
3750                        assignments.push((col, expr));
3751                        if matches!(self.peek(), Token::Comma) {
3752                            self.advance();
3753                            continue;
3754                        }
3755                        break;
3756                    }
3757                    crate::ast::MergeAction::Update { assignments }
3758                }
3759                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3760                    self.advance();
3761                    crate::ast::MergeAction::Delete
3762                }
3763                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3764                    self.advance();
3765                    let is_nothing_kw = matches!(
3766                        self.peek(),
3767                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3768                    );
3769                    if !is_nothing_kw {
3770                        return Err(self.err(format!(
3771                            "expected NOTHING after DO in MERGE clause, got {:?}",
3772                            self.peek()
3773                        )));
3774                    }
3775                    self.advance();
3776                    crate::ast::MergeAction::DoNothing
3777                }
3778                other => {
3779                    return Err(self.err(format!(
3780                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3781                    )));
3782                }
3783            };
3784            clauses.push(crate::ast::MergeWhenClause {
3785                matched,
3786                condition,
3787                action,
3788            });
3789        }
3790        if clauses.is_empty() {
3791            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3792        }
3793        Ok(Statement::Merge(crate::ast::MergeStatement {
3794            target,
3795            target_alias,
3796            source,
3797            source_alias,
3798            on,
3799            clauses,
3800        }))
3801    }
3802
3803    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3804    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3805    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3806    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3807    fn parse_optional_returning(
3808        &mut self,
3809    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3810        let is_returning_kw = matches!(
3811            self.peek(),
3812            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3813        );
3814        if !is_returning_kw {
3815            return Ok(None);
3816        }
3817        self.advance();
3818        let mut items = Vec::new();
3819        loop {
3820            items.push(self.parse_select_item()?);
3821            if matches!(self.peek(), Token::Comma) {
3822                self.advance();
3823                continue;
3824            }
3825            break;
3826        }
3827        Ok(Some(items))
3828    }
3829
3830    /// v6.0.4 — parse the tail of an ALTER statement after the
3831    /// leading `ALTER` keyword has been consumed. Only one form is
3832    /// supported in v6.0.4:
3833    ///
3834    /// ```text
3835    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
3836    /// ```
3837    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
3838        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
3839        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
3840        // exclusion) is accepted by stripping the `ONLY` keyword
3841        // before the table parse.
3842        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
3843        // and the long PG-dump tail are accepted as no-ops.
3844        match self.advance() {
3845            Token::Index => {}
3846            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
3847            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
3848            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
3849            Token::Table => {
3850                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3851                    self.advance();
3852                }
3853                return self.parse_alter_table_after_keyword();
3854            }
3855            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
3856                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3857                    self.advance();
3858                }
3859                return self.parse_alter_table_after_keyword();
3860            }
3861            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
3862            // of the silent-noop tail.
3863            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
3864                return self.parse_alter_sequence_after_keyword();
3865            }
3866            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
3867            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
3868            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
3869            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
3870            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
3871            Token::Ident(s) | Token::QuotedIdent(s)
3872                if matches!(
3873                    s.to_ascii_lowercase().as_str(),
3874                    "view"
3875                        | "function"
3876                        | "type"
3877                        | "domain"
3878                        | "database"
3879                        | "role"
3880                        | "schema"
3881                        | "owner"
3882                        | "default"
3883                        | "extension"
3884                        | "materialized"
3885                        | "policy"
3886                        | "publication"
3887                        | "subscription"
3888                ) =>
3889            {
3890                self.consume_until_statement_boundary();
3891                return Ok(Statement::Empty);
3892            }
3893            other => {
3894                return Err(self.err(format!(
3895                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
3896                     after ALTER, got {other:?}"
3897                )));
3898            }
3899        }
3900        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
3901        // (mailrs migrate-042 ships these). The presence of an
3902        // IF EXISTS makes the subsequent name lookup tolerate
3903        // a missing index — engine returns CommandOk no-op.
3904        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3905            let next = self.tokens.get(self.pos + 1);
3906            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3907                self.advance();
3908                self.advance();
3909                true
3910            } else {
3911                false
3912            }
3913        } else {
3914            false
3915        };
3916        let name = self.expect_ident_like()?;
3917        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
3918        // Detect BEFORE the REBUILD path so the existing REBUILD
3919        // arm stays untouched.
3920        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
3921            self.advance();
3922            if matches!(self.peek(), Token::To) {
3923                self.advance();
3924            } else {
3925                self.expect_keyword_ident("to")?;
3926            }
3927            let new = self.expect_ident_like()?;
3928            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3929                name,
3930                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
3931            }));
3932        }
3933        // REBUILD
3934        self.expect_keyword_ident("rebuild")?;
3935        // Optional: WITH (encoding = <enc>)
3936        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3937            self.advance();
3938            if !matches!(self.peek(), Token::LParen) {
3939                return Err(self.err(format!(
3940                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
3941                    self.peek()
3942                )));
3943            }
3944            self.advance();
3945            self.expect_keyword_ident("encoding")?;
3946            if !matches!(self.peek(), Token::Eq) {
3947                return Err(self.err(format!(
3948                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
3949                    self.peek()
3950                )));
3951            }
3952            self.advance();
3953            let enc_ident = match self.advance() {
3954                Token::Ident(s) | Token::QuotedIdent(s) => s,
3955                other => {
3956                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
3957                }
3958            };
3959            let enc = match enc_ident.to_ascii_lowercase().as_str() {
3960                "f32" => VecEncoding::F32,
3961                "sq8" => VecEncoding::Sq8,
3962                "half" => VecEncoding::F16,
3963                other => {
3964                    return Err(self.err(format!(
3965                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
3966                    )));
3967                }
3968            };
3969            if !matches!(self.peek(), Token::RParen) {
3970                return Err(self.err(format!(
3971                    "expected ')' after encoding value, got {:?}",
3972                    self.peek()
3973                )));
3974            }
3975            self.advance();
3976            Some(enc)
3977        } else {
3978            None
3979        };
3980        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3981            name,
3982            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
3983        }))
3984    }
3985
3986    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
3987    /// only `SET` form currently supported; future v6.7.x can add
3988    /// more SET subjects without changing the dispatch shape.
3989    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
3990    /// subactions. Single-subaction shape stays a 1-element vec.
3991    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
3992        let table_name = self.expect_ident_like()?;
3993        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
3994        loop {
3995            let subaction = self.parse_alter_table_subaction()?;
3996            // ADD COLUMN with inline REFERENCES emits both an
3997            // AddColumn and an AddForeignKey subaction; the
3998            // helper returns 1 or 2 items.
3999            targets.extend(subaction);
4000            if matches!(self.peek(), Token::Comma) {
4001                self.advance();
4002                continue;
4003            }
4004            break;
4005        }
4006        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
4007            name: table_name,
4008            targets,
4009        }))
4010    }
4011
4012    /// Parse one ALTER TABLE subaction. Returns a Vec because
4013    /// inline `REFERENCES` on `ADD COLUMN` produces both an
4014    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
4015    fn parse_alter_table_subaction(
4016        &mut self,
4017    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
4018        match self.peek() {
4019            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4020                self.advance();
4021                let setting = self.expect_ident_like()?;
4022                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
4023                    return Err(self.err(alloc::format!(
4024                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
4025                    )));
4026                }
4027                if !matches!(self.peek(), Token::Eq) {
4028                    return Err(self.err(alloc::format!(
4029                        "expected '=' after hot_tier_bytes, got {:?}",
4030                        self.peek()
4031                    )));
4032                }
4033                self.advance();
4034                let n = self.expect_u64_literal()?;
4035                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
4036            }
4037            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4038                self.advance();
4039                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
4040                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
4041                // PRIMARY KEY this way; mysqldump emits both.
4042                // Peek-only dispatch (no advance) — `advance()`
4043                // destructively replaces consumed tokens with Eof,
4044                // so saved-pos restore would land on Eofs.
4045                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
4046                {
4047                    // The next-but-one ident is the constraint
4048                    // name; the one after THAT is the kind.
4049                    let kind_pos = self.pos + 2;
4050                    let kind = self.tokens.get(kind_pos).cloned();
4051                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
4052                    {
4053                        let fk = self.parse_table_level_fk()?;
4054                        return Ok(alloc::vec![
4055                            crate::ast::AlterTableTarget::AddForeignKey(fk)
4056                        ]);
4057                    }
4058                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
4059                    {
4060                        self.advance(); // CONSTRAINT
4061                        let _name = self.expect_ident_like()?;
4062                        self.advance(); // PRIMARY
4063                        self.expect_keyword_ident("key")?;
4064                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4065                        return Ok(alloc::vec![
4066                            crate::ast::AlterTableTarget::AddTableConstraint(
4067                                crate::ast::TableConstraint::PrimaryKey {
4068                                    name: None,
4069                                    columns: cols,
4070                                }
4071                            )
4072                        ]);
4073                    }
4074                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
4075                    {
4076                        self.advance(); // CONSTRAINT
4077                        let _name = self.expect_ident_like()?;
4078                        // v7.22 (mailrs round-13 gap 6) — delegate so
4079                        // the optional `NULLS [NOT] DISTINCT` modifier
4080                        // parses here too (pg_dump emits the ALTER
4081                        // form; semantics enforced by the engine
4082                        // since v7.13).
4083                        let uc = self.parse_table_level_unique()?;
4084                        return Ok(alloc::vec![
4085                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4086                        ]);
4087                    }
4088                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
4089                    {
4090                        self.advance(); // CONSTRAINT
4091                        let _name = self.expect_ident_like()?;
4092                        self.advance(); // CHECK
4093                        if !matches!(self.peek(), Token::LParen) {
4094                            return Err(self.err(alloc::format!(
4095                                "expected '(' after CHECK, got {:?}", self.peek()
4096                            )));
4097                        }
4098                        self.advance();
4099                        let expr = self.parse_expr(0)?;
4100                        if matches!(self.peek(), Token::RParen) {
4101                            self.advance();
4102                        }
4103                        return Ok(alloc::vec![
4104                            crate::ast::AlterTableTarget::AddTableConstraint(
4105                                crate::ast::TableConstraint::Check { name: None, expr }
4106                            )
4107                        ]);
4108                    }
4109                    // Unknown kind — fall through to FK path which
4110                    // produces a descriptive parse error.
4111                }
4112                let is_fk = matches!(
4113                    self.peek(),
4114                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
4115                        || s.eq_ignore_ascii_case("foreign")
4116                );
4117                if is_fk {
4118                    let fk = self.parse_table_level_fk()?;
4119                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
4120                }
4121                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
4122                // (no CONSTRAINT prefix) — same dispatch.
4123                match self.peek().clone() {
4124                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
4125                        self.advance();
4126                        self.expect_keyword_ident("key")?;
4127                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4128                        return Ok(alloc::vec![
4129                            crate::ast::AlterTableTarget::AddTableConstraint(
4130                                crate::ast::TableConstraint::PrimaryKey {
4131                                    name: None,
4132                                    columns: cols,
4133                                }
4134                            )
4135                        ]);
4136                    }
4137                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4138                        // v7.22 — delegate (NULLS [NOT] DISTINCT).
4139                        let uc = self.parse_table_level_unique()?;
4140                        return Ok(alloc::vec![
4141                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4142                        ]);
4143                    }
4144                    _ => {}
4145                }
4146                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4147                    self.advance();
4148                }
4149                let mut if_not_exists = false;
4150                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4151                    self.advance();
4152                    if !matches!(self.peek(), Token::Not) {
4153                        return Err(self.err(alloc::format!(
4154                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
4155                            self.peek()
4156                        )));
4157                    }
4158                    self.advance();
4159                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
4160                        return Err(self.err(alloc::format!(
4161                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
4162                            self.peek()
4163                        )));
4164                    }
4165                    self.advance();
4166                    if_not_exists = true;
4167                }
4168                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
4169                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
4170                // returns ColumnDef + an optional inline FK.
4171                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
4172                let col_name = column.name.clone();
4173                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
4174                    column,
4175                    if_not_exists,
4176                }];
4177                if let Some(mut fk) = col_level_fk {
4178                    if fk.columns.is_empty() {
4179                        fk.columns.push(col_name);
4180                    }
4181                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
4182                }
4183                Ok(out)
4184            }
4185            Token::Drop => {
4186                self.advance();
4187                // v7.13.3 — dispatch on the next token. mailrs round-7
4188                // S8 closed DROP COLUMN; round-6 S7 closed
4189                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
4190                // RESTRICT modifiers.
4191                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
4192                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
4193                let subject = match self.peek() {
4194                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
4195                        self.advance();
4196                        "constraint"
4197                    }
4198                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
4199                        self.advance();
4200                        "column"
4201                    }
4202                    // PG-canonical bare `DROP <col>` without COLUMN
4203                    // keyword is also valid; treat any other ident
4204                    // as the column name.
4205                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
4206                    other => {
4207                        return Err(self.err(alloc::format!(
4208                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
4209                        )));
4210                    }
4211                };
4212                let mut if_exists = false;
4213                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4214                    let n1 = self.tokens.get(self.pos + 1);
4215                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4216                        self.advance();
4217                        self.advance();
4218                        if_exists = true;
4219                    }
4220                }
4221                let name = self.expect_ident_like()?;
4222                let mut cascade = false;
4223                if matches!(
4224                    self.peek(),
4225                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
4226                        || s.eq_ignore_ascii_case("restrict")
4227                ) {
4228                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
4229                    {
4230                        cascade = true;
4231                    }
4232                    self.advance();
4233                }
4234                if subject == "constraint" {
4235                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
4236                        name,
4237                        if_exists,
4238                    }])
4239                } else {
4240                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
4241                        column: name,
4242                        if_exists,
4243                        cascade,
4244                    }])
4245                }
4246            }
4247            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
4248                self.advance();
4249                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4250                    self.advance();
4251                }
4252                let col_name = self.expect_ident_like()?;
4253                match self.peek() {
4254                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
4255                        self.advance();
4256                    }
4257                    // v7.14.0 — pg_dump emits BIGSERIAL via
4258                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
4259                    // nextval('seq')` (the sequence is created
4260                    // separately). SPG's BIGSERIAL already uses
4261                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4262                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4263                    // engine no-ops by consuming the tail.
4264                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4265                        // v7.22 (round-13 T2) — `SET DEFAULT
4266                        // nextval('…')` is how pg_dump spells a
4267                        // SERIAL column (plain integer in CREATE
4268                        // TABLE + this ALTER). It used to be
4269                        // swallowed as a no-op, which silently
4270                        // STRIPPED auto-increment from imported
4271                        // schemas — the first post-import INSERT
4272                        // without an explicit id then violated NOT
4273                        // NULL. Lower it to the auto-increment
4274                        // marker instead.
4275                        let is_default_nextval =
4276                            matches!(self.tokens.get(self.pos + 1), Some(Token::Default))
4277                                && matches!(
4278                                    self.tokens.get(self.pos + 2),
4279                                    Some(Token::Ident(f)) if f.eq_ignore_ascii_case("nextval")
4280                                );
4281                        // Capture the nextval target so the engine
4282                        // can guarantee the sequence exists.
4283                        let seq_name = if is_default_nextval {
4284                            self.scan_sequence_name_until_boundary()
4285                        } else {
4286                            self.consume_until_statement_boundary();
4287                            None
4288                        };
4289                        if is_default_nextval {
4290                            return Ok(alloc::vec![
4291                                crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4292                                    column: col_name,
4293                                    seq_name,
4294                                }
4295                            ]);
4296                        }
4297                        // Other SET DEFAULT … / SET NOT NULL forms
4298                        // stay engine no-ops (real defaults arrive
4299                        // inline in CREATE TABLE in every dump;
4300                        // nullability change would need a row scan
4301                        // — deferred).
4302                        return Ok(Vec::new());
4303                    }
4304                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4305                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4306                        self.consume_until_statement_boundary();
4307                        return Ok(Vec::new());
4308                    }
4309                    Token::Drop => {
4310                        // v7.37.43-T4 — same path as the Ident("drop")
4311                        // arm above. `DROP` is unreserved per PG; the
4312                        // lexer emits `Token::Drop` so the publication-
4313                        // DROP path can dispatch on it, but ALTER COLUMN
4314                        // DROP DEFAULT / DROP NOT NULL must also work.
4315                        self.consume_until_statement_boundary();
4316                        return Ok(Vec::new());
4317                    }
4318                    Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4319                        // v7.22 (round-13 T2) — `ALTER COLUMN c ADD
4320                        // GENERATED { ALWAYS | BY DEFAULT } AS
4321                        // IDENTITY ( … )`: pg_dump's spelling for
4322                        // identity columns. Same auto-increment
4323                        // lowering as the nextval default; the
4324                        // sequence options inside the parens are
4325                        // no-ops under SPG's max+1 semantics.
4326                        let is_generated = matches!(
4327                            self.tokens.get(self.pos + 1),
4328                            Some(Token::Ident(g)) if g.eq_ignore_ascii_case("generated")
4329                        );
4330                        if !is_generated {
4331                            return Err(self.err(alloc::format!(
4332                                "expected GENERATED after ALTER COLUMN {col_name} ADD, got {:?}",
4333                                self.tokens.get(self.pos + 1)
4334                            )));
4335                        }
4336                        let seq_name = self.scan_sequence_name_until_boundary();
4337                        return Ok(alloc::vec![
4338                            crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4339                                column: col_name,
4340                                seq_name,
4341                            }
4342                        ]);
4343                    }
4344                    other => {
4345                        return Err(self.err(alloc::format!(
4346                            "expected TYPE / SET / DROP / ADD after ALTER COLUMN <name>, got {other:?}"
4347                        )));
4348                    }
4349                }
4350                let new_type = self.parse_column_type_name()?;
4351                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4352                {
4353                    self.advance();
4354                    Some(self.parse_expr(0)?)
4355                } else {
4356                    None
4357                };
4358                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4359                    column: col_name,
4360                    new_type,
4361                    using,
4362                }])
4363            }
4364            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4365            // PG also supports `RENAME TO new_table` for table-name
4366            // rename; that surface is deferred (pg_dump never emits
4367            // it). If the first post-RENAME ident is `TO`, the user
4368            // is asking for table rename — error with a clear
4369            // message rather than misparsing `TO` as a column name.
4370            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4371                self.advance();
4372                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4373                // table-name rename (mailrs round-10 A.5 — used
4374                // by migrate-042's `RENAME TO email_contacts`).
4375                // `TO` lexes as Token::To.
4376                if matches!(self.peek(), Token::To)
4377                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4378                {
4379                    self.advance();
4380                    let new = self.expect_ident_like()?;
4381                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4382                        new,
4383                    }]);
4384                }
4385                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4386                    self.advance();
4387                }
4388                let old = self.expect_ident_like()?;
4389                // `TO` is a reserved keyword token; accept both
4390                // Token::To and Token::Ident("to") for consistency.
4391                if matches!(self.peek(), Token::To) {
4392                    self.advance();
4393                } else {
4394                    self.expect_keyword_ident("to")?;
4395                }
4396                let new = self.expect_ident_like()?;
4397                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4398                    old,
4399                    new,
4400                }])
4401            }
4402            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4403            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4404            // every data block with these. Real disable semantics —
4405            // not no-op — because reload correctness assumes the
4406            // triggers don't fire (rows already carry their
4407            // computed values from prod).
4408            Token::Ident(s)
4409                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4410            {
4411                let enabled = s.eq_ignore_ascii_case("enable");
4412                self.advance();
4413                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4414                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4415                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4416                // pg_dump output) — anything else falls through to
4417                // the catch-all error below.
4418                // v7.22 (round-13 T3) — mysqldump wraps every data
4419                // section in `/*!40000 ALTER TABLE t DISABLE KEYS */`
4420                // + ENABLE KEYS (a MyISAM index-rebuild hint). SPG
4421                // maintains indexes incrementally — engine no-op.
4422                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("keys")) {
4423                    self.advance();
4424                    return Ok(Vec::new());
4425                }
4426                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4427                    return Err(self.err(alloc::format!(
4428                        "expected TRIGGER after {}, got {:?}",
4429                        if enabled { "ENABLE" } else { "DISABLE" },
4430                        self.peek()
4431                    )));
4432                }
4433                self.advance();
4434                // `ALL` lexes as Token::All (reserved); also
4435                // accept Token::Ident("all") for symmetry.
4436                let which = if matches!(self.peek(), Token::All)
4437                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4438                {
4439                    self.advance();
4440                    crate::ast::TriggerSelector::All
4441                } else {
4442                    let name = self.expect_ident_like()?;
4443                    crate::ast::TriggerSelector::Named(name)
4444                };
4445                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4446                    which,
4447                    enabled,
4448                }])
4449            }
4450            other => Err(self.err(alloc::format!(
4451                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4452            ))),
4453        }
4454    }
4455
4456    /// v7.16.2 — peek for `information_schema.<tbl>` /
4457    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4458    /// three tokens + return a synthetic table name the engine's
4459    /// SELECT path recognises as a virtual view. Returns `None`
4460    /// when the head doesn't look like a meta-qualified name.
4461    /// Used by `parse_table_ref` to bypass the
4462    /// `expect_ident_like` schema-strip for these specific PG
4463    /// meta schemas (mailrs round-10 A.3).
4464    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4465        // Extract the schema name. Must be a plain ident token.
4466        let schema = match self.tokens.get(self.pos) {
4467            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4468            _ => return None,
4469        };
4470        // Dot.
4471        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4472            return None;
4473        }
4474        // The table-side ident may lex as a reserved keyword
4475        // (e.g. `Token::Tables`). Tolerate the common ones via a
4476        // helper that reads the trailing token's underlying name.
4477        let tbl = match self.tokens.get(self.pos + 2)? {
4478            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4479            Token::Tables => "tables".to_string(),
4480            // Other PG meta table names that may collide with
4481            // reserved keywords land here as needed.
4482            _ => return None,
4483        };
4484        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4485        // names so the synthetic name doesn't double-prefix
4486        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4487        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4488            ("__spg_info_", tbl.to_ascii_lowercase())
4489        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4490            let bare = tbl
4491                .to_ascii_lowercase()
4492                .strip_prefix("pg_")
4493                .map(alloc::string::String::from)
4494                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4495            ("__spg_pg_", bare)
4496        } else if schema.eq_ignore_ascii_case("mysql") {
4497            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4498            // (`mysql.user`, `mysql.db`). Same synthetic-name
4499            // shape as pg_catalog.
4500            ("__spg_mysql_", tbl.to_ascii_lowercase())
4501        } else {
4502            return None;
4503        };
4504        self.advance(); // schema
4505        self.advance(); // dot
4506        self.advance(); // tbl
4507        Some(alloc::format!("{prefix}{normalised}"))
4508    }
4509
4510    /// Unqualified PG meta-table names (`FROM pg_extension`, `FROM
4511    /// pg_class`) resolve the same way: PG puts `pg_catalog` at the
4512    /// implicit front of every search_path, so a bare reference to a
4513    /// known catalog table always means the catalog table. Only the
4514    /// names the engine actually synthesises are recognised — any
4515    /// other `pg_*` ident stays a user table (mailrs embed round-12).
4516    fn try_peek_meta_bare(&mut self) -> Option<String> {
4517        const PG_META_TABLES: &[&str] = &[
4518            "pg_attribute",
4519            "pg_class",
4520            "pg_constraint",
4521            "pg_database",
4522            "pg_extension",
4523            "pg_index",
4524            "pg_indexes",
4525            "pg_matviews",
4526            "pg_namespace",
4527            "pg_proc",
4528            "pg_roles",
4529            "pg_settings",
4530            "pg_trigger",
4531            "pg_type",
4532            "pg_user",
4533            "pg_views",
4534        ];
4535        let name = match self.tokens.get(self.pos) {
4536            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4537            _ => return None,
4538        };
4539        // A following dot means this ident is a schema qualifier,
4540        // not a table name — let the qualified path handle it.
4541        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4542            return None;
4543        }
4544        if !PG_META_TABLES.contains(&name.as_str()) {
4545            return None;
4546        }
4547        self.advance();
4548        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4549        Some(alloc::format!("__spg_pg_{bare}"))
4550    }
4551
4552    /// Consume a bare ident if its lowercase matches `kw`, else err.
4553    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4554        match self.advance() {
4555            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4556            other => Err(ParseError {
4557                message: format!("expected {kw:?}, got {other:?}"),
4558                token_pos: self.pos.saturating_sub(1),
4559            }),
4560        }
4561    }
4562
4563    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4564    /// literal (`'foo'`) — same shape used by CREATE USER for the
4565    /// username slot.
4566    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4567        match self.advance() {
4568            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4569            other => Err(ParseError {
4570                message: format!("expected identifier or string, got {other:?}"),
4571                token_pos: self.pos.saturating_sub(1),
4572            }),
4573        }
4574    }
4575
4576    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4577        match self.advance() {
4578            Token::String(s) => Ok(s),
4579            other => Err(ParseError {
4580                message: format!("expected quoted string, got {other:?}"),
4581                token_pos: self.pos.saturating_sub(1),
4582            }),
4583        }
4584    }
4585
4586    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4587        // v7.30.2 (mailrs round-25 ask 2) — derived tables /
4588        // subqueries recurse through here without passing
4589        // parse_expr; share the same nesting budget.
4590        self.enter_nested()?;
4591        let r = self.parse_select_stmt_inner();
4592        self.nest_depth -= 1;
4593        r
4594    }
4595
4596    fn parse_select_stmt_inner(&mut self) -> Result<Statement, ParseError> {
4597        // Caller dispatches on Token::Select; the inner helper handles
4598        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4599        // get a fresh bare-select parse and may not have their own ORDER
4600        // BY / LIMIT.
4601        let mut head = self.parse_bare_select()?;
4602        while matches!(self.peek(), Token::Union) {
4603            self.advance();
4604            let kind = if matches!(self.peek(), Token::All) {
4605                self.advance();
4606                UnionKind::All
4607            } else {
4608                UnionKind::Distinct
4609            };
4610            let peer = self.parse_bare_select()?;
4611            head.unions.push((kind, peer));
4612        }
4613        head.order_by = if matches!(self.peek(), Token::Order) {
4614            self.advance();
4615            if !matches!(self.peek(), Token::By) {
4616                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4617            }
4618            self.advance();
4619            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4620            // `<expr> [ASC|DESC]` items.
4621            let mut keys = Vec::new();
4622            loop {
4623                let expr = self.parse_expr(0)?;
4624                let desc = if matches!(self.peek(), Token::Desc) {
4625                    self.advance();
4626                    true
4627                } else if matches!(self.peek(), Token::Asc) {
4628                    self.advance();
4629                    false
4630                } else {
4631                    false
4632                };
4633                // v7.24 (round-16 A) — explicit NULLS FIRST/LAST.
4634                let nulls_first = self.parse_optional_nulls_placement()?;
4635                keys.push(OrderBy {
4636                    expr,
4637                    desc,
4638                    nulls_first,
4639                });
4640                if matches!(self.peek(), Token::Comma) {
4641                    self.advance();
4642                } else {
4643                    break;
4644                }
4645            }
4646            keys
4647        } else {
4648            Vec::new()
4649        };
4650        head.limit = if matches!(self.peek(), Token::Limit) {
4651            self.advance();
4652            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4653            // PG synonyms for "no limit". Treat both as None
4654            // (no head.limit set) so the engine's existing
4655            // unlimited-result path takes over. Reject was the
4656            // pre-5.1 behaviour and broke pg_dump-flavoured
4657            // tooling that occasionally emits LIMIT NULL.
4658            if self.consume_limit_unbounded_sentinel() {
4659                None
4660            } else {
4661                Some(self.parse_limit_expr("LIMIT")?)
4662            }
4663        } else {
4664            None
4665        };
4666        head.offset = if matches!(self.peek(), Token::Offset) {
4667            self.advance();
4668            // PG also accepts an optional `ROW` / `ROWS` trailer
4669            // after the offset value (`OFFSET 10 ROWS`). The
4670            // FETCH-FIRST branch below relies on the same.
4671            let off = self.parse_limit_expr("OFFSET")?;
4672            self.consume_optional_rows_keyword();
4673            Some(off)
4674        } else {
4675            None
4676        };
4677        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4678        // the SQL-standard alias for LIMIT. PG accepts both
4679        // spellings interchangeably; pg_dump emits FETCH FIRST in
4680        // newer versions. We map it onto `head.limit` so the
4681        // engine path is unified.
4682        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4683        {
4684            self.advance(); // FETCH
4685            // `FIRST` or `NEXT` (both legal per SQL standard).
4686            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4687                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4688            {
4689                self.advance();
4690            }
4691            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4692            // implicit 1 — but we always consume one if present).
4693            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4694                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4695            {
4696                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4697                crate::ast::LimitExpr::Literal(1)
4698            } else {
4699                self.parse_limit_expr("FETCH FIRST")?
4700            };
4701            // Eat `ROW` / `ROWS` if not already consumed above.
4702            self.consume_optional_rows_keyword();
4703            // Optional `ONLY` (the spec form) — or the SQL:2008
4704            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4705            // now honours WITH TIES by extending past the LIMIT
4706            // truncation point through every row that shares the
4707            // last-kept row's ORDER BY key.
4708            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4709                if s.eq_ignore_ascii_case("only"))
4710            {
4711                self.advance();
4712            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4713                if s.eq_ignore_ascii_case("with"))
4714            {
4715                self.advance(); // WITH
4716                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4717                    if s.eq_ignore_ascii_case("ties"))
4718                {
4719                    self.advance();
4720                    head.limit_with_ties = true;
4721                }
4722            }
4723            head.limit = Some(count);
4724        }
4725        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4726        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4727        //       [ OF table_name [, …] ]
4728        //       [ NOWAIT | SKIP LOCKED ]
4729        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4730        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4731        // SELECT already returns a consistent snapshot — so these
4732        // are accept-and-discard: the parser absorbs them so
4733        // mailrs / Rails / Django code paths that emit `SELECT
4734        // … FOR UPDATE` for advisory pessimistic locking load
4735        // without a parser error. The on-disk locking model is
4736        // unchanged; callers that rely on FOR UPDATE for read-
4737        // through-write ordering still get the right answer
4738        // because SPG serialises writes anyway.
4739        self.consume_optional_for_lock_clauses();
4740        Ok(Statement::Select(head))
4741    }
4742
4743    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4744    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4745    /// LOCKED ]` trailers. Each clause is fully accepted and
4746    /// discarded — SPG's single-writer model already satisfies the
4747    /// callers' implicit ordering requirement. Stops at the first
4748    /// token that isn't `FOR`.
4749    fn consume_optional_for_lock_clauses(&mut self) {
4750        while matches!(self.peek(), Token::For) {
4751            self.advance(); // FOR
4752            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4753            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4754            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4755                if s.eq_ignore_ascii_case("no"))
4756            {
4757                self.advance(); // NO
4758                // The next ident should be KEY but be generous;
4759                // anything followed by UPDATE/SHARE is accepted.
4760                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4761                    if s.eq_ignore_ascii_case("key"))
4762                {
4763                    self.advance(); // KEY
4764                }
4765            }
4766            // `KEY` prefix (PG `FOR KEY SHARE`).
4767            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4768                if s.eq_ignore_ascii_case("key"))
4769            {
4770                self.advance(); // KEY
4771            }
4772            // Lock-strength keyword: UPDATE / SHARE. Required, but
4773            // we're lenient — an unexpected token here just bails
4774            // (we already consumed FOR; caller's downstream
4775            // dispatch will error if anything actually depends on
4776            // the trailing tokens).
4777            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4778                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4779            {
4780                self.advance();
4781            } else {
4782                // FOR by itself (or `FOR KEY` with nothing after) —
4783                // give up on the lock-clause path. We've already
4784                // advanced past FOR; further attempts to parse
4785                // here would clobber state.
4786                return;
4787            }
4788            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4789            // joining and locking only a subset of tables.
4790            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4791                if s.eq_ignore_ascii_case("of"))
4792            {
4793                self.advance(); // OF
4794                #[allow(clippy::while_let_loop)]
4795                loop {
4796                    match self.peek() {
4797                        Token::Ident(_) | Token::QuotedIdent(_) => {
4798                            self.advance();
4799                            // Optional schema-qualified `schema.table`.
4800                            if matches!(self.peek(), Token::Dot) {
4801                                self.advance();
4802                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4803                                    self.advance();
4804                                }
4805                            }
4806                        }
4807                        _ => break,
4808                    }
4809                    if matches!(self.peek(), Token::Comma) {
4810                        self.advance();
4811                    } else {
4812                        break;
4813                    }
4814                }
4815            }
4816            // Optional `NOWAIT` | `SKIP LOCKED`.
4817            match self.peek().clone() {
4818                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4819                    self.advance();
4820                }
4821                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4822                    self.advance(); // SKIP
4823                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4824                        if s.eq_ignore_ascii_case("locked"))
4825                    {
4826                        self.advance(); // LOCKED
4827                    }
4828                }
4829                _ => {}
4830            }
4831            // Loop: PG allows multiple FOR clauses chained.
4832        }
4833    }
4834
4835    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4836    /// Bind value gets resolved during prepared-statement Execute;
4837    /// the Pratt expression parser would over-accept here (e.g.
4838    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4839    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4840    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4841    /// when one was consumed; caller skips the regular
4842    /// limit-value parse and leaves `head.limit` at None.
4843    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4844        if matches!(self.peek(), Token::Null) {
4845            self.advance();
4846            return true;
4847        }
4848        if matches!(self.peek(), Token::All) {
4849            self.advance();
4850            return true;
4851        }
4852        false
4853    }
4854
4855    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4856    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4857    /// SQL-standard shape. No-op when missing.
4858    fn consume_optional_rows_keyword(&mut self) {
4859        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4860            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4861        {
4862            self.advance();
4863        }
4864    }
4865
4866    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4867        match self.advance() {
4868            Token::Integer(n) if n >= 0 => u32::try_from(n)
4869                .map(crate::ast::LimitExpr::Literal)
4870                .map_err(|_| ParseError {
4871                    message: alloc::format!("{label} value too large: {n}"),
4872                    token_pos: self.pos.saturating_sub(1),
4873                }),
4874            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4875            other => Err(ParseError {
4876                message: alloc::format!(
4877                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4878                ),
4879                token_pos: self.pos.saturating_sub(1),
4880            }),
4881        }
4882    }
4883
4884    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4885    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4886    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4887    /// `parse_select_stmt` is responsible for filling those in.
4888    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4889        if !matches!(self.peek(), Token::Select) {
4890            return Err(self.err(format!(
4891                "expected SELECT to start a query block, got {:?}",
4892                self.peek()
4893            )));
4894        }
4895        self.advance();
4896        let distinct = if matches!(self.peek(), Token::Distinct) {
4897            self.advance();
4898            true
4899        } else {
4900            false
4901        };
4902        let items = self.parse_select_list()?;
4903        let from = if matches!(self.peek(), Token::From) {
4904            self.advance();
4905            Some(self.parse_from_clause()?)
4906        } else {
4907            None
4908        };
4909        let where_ = if matches!(self.peek(), Token::Where) {
4910            self.advance();
4911            Some(self.parse_expr(0)?)
4912        } else {
4913            None
4914        };
4915        let mut group_by_all = false;
4916        let group_by = if matches!(self.peek(), Token::Group) {
4917            self.advance();
4918            if !matches!(self.peek(), Token::By) {
4919                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4920            }
4921            self.advance();
4922            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4923            // every non-aggregate SELECT-list item later.
4924            if matches!(self.peek(), Token::All) {
4925                self.advance();
4926                group_by_all = true;
4927                None
4928            } else {
4929                let mut groups = Vec::new();
4930                loop {
4931                    groups.push(self.parse_expr(0)?);
4932                    if matches!(self.peek(), Token::Comma) {
4933                        self.advance();
4934                    } else {
4935                        break;
4936                    }
4937                }
4938                Some(groups)
4939            }
4940        } else {
4941            None
4942        };
4943        let having = if matches!(self.peek(), Token::Having) {
4944            self.advance();
4945            Some(self.parse_expr(0)?)
4946        } else {
4947            None
4948        };
4949        Ok(SelectStatement {
4950            ctes: Vec::new(),
4951            distinct,
4952            items,
4953            from,
4954            where_,
4955            group_by,
4956            group_by_all,
4957            having,
4958            unions: Vec::new(),
4959            order_by: Vec::new(),
4960            limit: None,
4961            offset: None,
4962            limit_with_ties: false,
4963        })
4964    }
4965
4966    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4967        // Caller already consumed CREATE; we're sitting on TABLE.
4968        debug_assert!(matches!(self.peek(), Token::Table));
4969        self.advance();
4970        let if_not_exists = self.consume_if_not_exists();
4971        let name = self.expect_ident_like()?;
4972        // v7.37.6-B — `CREATE TABLE c PARTITION OF parent <bounds>`
4973        // child shape has no column list; the child inherits its
4974        // columns from the parent at engine-DDL time. Detect it
4975        // before the `(` requirement below.
4976        if matches!(self.peek(), Token::Partition)
4977            && Self::tokens_match_ident_ci(self.tokens.get(self.pos + 1), "of")
4978        {
4979            self.advance(); // PARTITION
4980            self.advance(); // of
4981            let partition_of = self.parse_partition_of_tail()?;
4982            return Ok(Statement::CreateTable(CreateTableStatement {
4983                name,
4984                columns: Vec::new(),
4985                if_not_exists,
4986                foreign_keys: Vec::new(),
4987                table_constraints: Vec::new(),
4988                partition_by: None,
4989                partition_of: Some(partition_of),
4990            }));
4991        }
4992        if !matches!(self.peek(), Token::LParen) {
4993            return Err(self.err(format!(
4994                "expected '(' after table name, got {:?}",
4995                self.peek()
4996            )));
4997        }
4998        self.advance();
4999        let mut columns = Vec::new();
5000        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
5001        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
5002        loop {
5003            // v7.6.0 / v7.9.18 — distinguish table-level constraint
5004            // clauses from column definitions. Constraints start
5005            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
5006            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
5007            // a column.
5008            if self.peek_table_level_pk_start() {
5009                table_constraints.push(self.parse_table_level_primary_key()?);
5010            } else if self.peek_table_level_unique_start() {
5011                table_constraints.push(self.parse_table_level_unique()?);
5012            } else if self.peek_table_level_check_start() {
5013                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
5014                table_constraints.push(self.parse_table_level_check()?);
5015            } else if self.peek_mysql_inline_key_start() {
5016                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
5017                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
5018                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
5019                // inside the column list. Skip name + paren list;
5020                // for UNIQUE KEY, register as a UC.
5021                if let Some(uc) = self.parse_mysql_inline_key()? {
5022                    table_constraints.push(uc);
5023                }
5024            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
5025                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
5026                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
5027                // CHECK is named, and the named-CONSTRAINT arm used
5028                // to accept FOREIGN KEY only. The name is accepted
5029                // and discarded — same handling as every other SPG
5030                // constraint name.
5031                self.advance(); // CONSTRAINT
5032                let _name = self.expect_ident_like()?;
5033                table_constraints.push(match kind {
5034                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
5035                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
5036                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
5037                });
5038            } else if self.peek_constraint_or_fk_start() {
5039                foreign_keys.push(self.parse_table_level_fk()?);
5040            } else {
5041                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
5042                // v7.13.0 — fold inline UNIQUE / CHECK column
5043                // constraints into table-level entries so the
5044                // engine path stays uniform.
5045                if col.is_unique {
5046                    table_constraints.push(crate::ast::TableConstraint::Unique {
5047                        name: None,
5048                        columns: alloc::vec![col.name.clone()],
5049                        nulls_not_distinct: false,
5050                    });
5051                }
5052                if let Some(check_expr) = col.check.clone() {
5053                    table_constraints.push(crate::ast::TableConstraint::Check {
5054                        name: None,
5055                        expr: check_expr,
5056                    });
5057                }
5058                columns.push(col);
5059                if let Some(fk) = col_level_fk {
5060                    foreign_keys.push(fk);
5061                }
5062            }
5063            match self.peek() {
5064                Token::Comma => {
5065                    self.advance();
5066                }
5067                Token::RParen => {
5068                    self.advance();
5069                    break;
5070                }
5071                other => {
5072                    return Err(
5073                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
5074                    );
5075                }
5076            }
5077        }
5078        if columns.is_empty() {
5079            return Err(self.err("CREATE TABLE requires at least one column".into()));
5080        }
5081        // v7.14.0 — consume MySQL/MariaDB table options after the
5082        // closing `)`. mysqldump emits things like
5083        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
5084        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
5085        // SPG accepts all forms as no-ops (each option is
5086        // `<ident> [=] <ident-or-string>` separated by whitespace).
5087        self.consume_mysql_table_options();
5088        // v7.37.6-B — declarative-partition-parent suffix
5089        // (`PARTITION BY RANGE (key_col)`) sits after the column
5090        // list + MySQL table-options. v7.37.6-B only accepts RANGE
5091        // and locks the key column at one ident; the engine then
5092        // verifies the column type is TIMESTAMPTZ.
5093        let partition_by = if matches!(self.peek(), Token::Partition) {
5094            self.advance(); // PARTITION
5095            if !matches!(self.peek(), Token::By) {
5096                return Err(self.err(format!(
5097                    "expected BY after PARTITION, got {:?}",
5098                    self.peek()
5099                )));
5100            }
5101            self.advance();
5102            Some(self.parse_partition_by_tail()?)
5103        } else {
5104            None
5105        };
5106        Ok(Statement::CreateTable(CreateTableStatement {
5107            name,
5108            columns,
5109            if_not_exists,
5110            foreign_keys,
5111            table_constraints,
5112            partition_by,
5113            partition_of: None,
5114        }))
5115    }
5116
5117    /// v7.37.6-B — case-insensitive ident match helper for the
5118    /// `PARTITION OF` / `MINVALUE` / `MAXVALUE` keywords. They lex
5119    /// as `Token::Ident("of"/"minvalue"/"maxvalue")` because we
5120    /// didn't burn a global keyword slot for each (see the
5121    /// `Token::Partition` doc-comment in `lexer.rs`).
5122    fn tokens_match_ident_ci(t: Option<&Token>, want: &str) -> bool {
5123        matches!(t, Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case(want))
5124    }
5125
5126    /// v7.37.6-B — after `PARTITION BY`, expect `RANGE (key_col [, ...])`.
5127    fn parse_partition_by_tail(&mut self) -> Result<crate::ast::PartitionBySpec, ParseError> {
5128        use crate::ast::{PartitionBySpec, PartitionKindAst};
5129        let kind = match self.peek() {
5130            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("range") => {
5131                self.advance();
5132                PartitionKindAst::Range
5133            }
5134            other => {
5135                return Err(self.err(format!(
5136                    "PARTITION BY: only RANGE is supported at v7.37.6-B, got {other:?}"
5137                )));
5138            }
5139        };
5140        if !matches!(self.peek(), Token::LParen) {
5141            return Err(self.err(format!(
5142                "expected '(' after PARTITION BY RANGE, got {:?}",
5143                self.peek()
5144            )));
5145        }
5146        self.advance();
5147        let mut key_columns = Vec::new();
5148        loop {
5149            key_columns.push(self.expect_ident_like()?);
5150            match self.peek() {
5151                Token::Comma => {
5152                    self.advance();
5153                }
5154                Token::RParen => {
5155                    self.advance();
5156                    break;
5157                }
5158                other => {
5159                    return Err(self.err(format!(
5160                        "expected ',' or ')' in PARTITION BY key list, got {other:?}"
5161                    )));
5162                }
5163            }
5164        }
5165        if key_columns.is_empty() {
5166            return Err(self.err("PARTITION BY RANGE requires at least one key column".to_string()));
5167        }
5168        Ok(PartitionBySpec { kind, key_columns })
5169    }
5170
5171    /// v7.37.6-B — after `PARTITION OF`, expect
5172    ///   <parent> FOR VALUES FROM ( <expr> ) TO ( <expr> )
5173    /// or
5174    ///   <parent> DEFAULT
5175    fn parse_partition_of_tail(&mut self) -> Result<crate::ast::PartitionOfSpec, ParseError> {
5176        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
5177        let parent_name = self.expect_ident_like()?;
5178        // v7.37.6-B rejects an explicit column list — the child
5179        // inherits from the parent. mailrs round-7 taught us that
5180        // CREATE TABLE-side schema reconciliation hides drift, so
5181        // we surface this as a parse error rather than silently
5182        // ignoring user columns.
5183        if matches!(self.peek(), Token::LParen) {
5184            return Err(self.err(
5185                "CREATE TABLE … PARTITION OF parent: explicit column list not supported \
5186                 at v7.37.6-B; the child inherits its columns from the parent"
5187                    .to_string(),
5188            ));
5189        }
5190        let bounds = match self.peek() {
5191            Token::Default => {
5192                self.advance();
5193                PartitionOfBoundsAst::Default
5194            }
5195            Token::For => {
5196                self.advance();
5197                if !matches!(self.peek(), Token::Values) {
5198                    return Err(
5199                        self.err(format!("expected VALUES after FOR, got {:?}", self.peek()))
5200                    );
5201                }
5202                self.advance();
5203                if !matches!(self.peek(), Token::From) {
5204                    return Err(self.err(format!(
5205                        "expected FROM after FOR VALUES, got {:?}",
5206                        self.peek()
5207                    )));
5208                }
5209                self.advance();
5210                let lower = Box::new(self.parse_partition_bound_expr()?);
5211                if !matches!(self.peek(), Token::To) {
5212                    return Err(self.err(format!(
5213                        "expected TO after FROM (...), got {:?}",
5214                        self.peek()
5215                    )));
5216                }
5217                self.advance();
5218                let upper = Box::new(self.parse_partition_bound_expr()?);
5219                PartitionOfBoundsAst::Range { lower, upper }
5220            }
5221            other => {
5222                return Err(self.err(format!(
5223                    "expected FOR VALUES or DEFAULT after PARTITION OF parent, got {other:?}"
5224                )));
5225            }
5226        };
5227        Ok(PartitionOfSpec {
5228            parent_name,
5229            bounds,
5230        })
5231    }
5232
5233    /// v7.37.6-B — a single `( <expr> )` bound. `MINVALUE` /
5234    /// `MAXVALUE` lex as Ident; rewrite them into FunctionCall
5235    /// markers (no-arg builtins) so the engine resolves them
5236    /// against [`spg_storage::PartitionBound::{MinValue, MaxValue}`].
5237    fn parse_partition_bound_expr(&mut self) -> Result<crate::ast::Expr, ParseError> {
5238        if !matches!(self.peek(), Token::LParen) {
5239            return Err(self.err(format!(
5240                "expected '(' before partition bound, got {:?}",
5241                self.peek()
5242            )));
5243        }
5244        self.advance();
5245        let expr = match self.peek() {
5246            Token::Ident(s) | Token::QuotedIdent(s)
5247                if s.eq_ignore_ascii_case("minvalue") || s.eq_ignore_ascii_case("maxvalue") =>
5248            {
5249                let name = s.to_ascii_uppercase();
5250                self.advance();
5251                crate::ast::Expr::FunctionCall {
5252                    name,
5253                    args: Vec::new(),
5254                }
5255            }
5256            _ => self.parse_expr(0)?,
5257        };
5258        if !matches!(self.peek(), Token::RParen) {
5259            return Err(self.err(format!(
5260                "expected ')' after partition bound, got {:?}",
5261                self.peek()
5262            )));
5263        }
5264        self.advance();
5265        Ok(expr)
5266    }
5267
5268    /// v7.14.0 — true when the next tokens look like an inline
5269    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
5270    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
5271    /// — each followed by an optional name + `(...)`. Critical:
5272    /// a column NAMED `key` / `index` (PG accepts as ident) must
5273    /// NOT be mistaken for the KEY constraint shape. We disambig
5274    /// by requiring the keyword to be followed by either `(` or
5275    /// `<ident> (`.
5276    fn peek_mysql_inline_key_start(&self) -> bool {
5277        let cur = self.peek();
5278        // Shapes:
5279        //   KEY (cols)
5280        //   KEY name (cols)
5281        //   INDEX (cols)
5282        //   INDEX name (cols)
5283        //   UNIQUE KEY [name] (cols)
5284        //   UNIQUE INDEX [name] (cols)
5285        //   FULLTEXT [KEY|INDEX] [name] (cols)
5286        //   SPATIAL [KEY|INDEX] [name] (cols)
5287        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
5288            // tokens at skip = the position AFTER the index-form
5289            // keywords (KEY/INDEX) have been consumed.
5290            match self.tokens.get(skip) {
5291                Some(Token::LParen) => true,
5292                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
5293                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
5294                }
5295                _ => false,
5296            }
5297        };
5298        // `INDEX` lexes as Token::Index (reserved), not as
5299        // Token::Ident("index"). Both shapes count as a KEY/INDEX
5300        // start; the peek helper below handles either.
5301        let is_key_or_index_tok = |t: &Token| -> bool {
5302            matches!(t, Token::Index)
5303                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
5304        };
5305        match cur {
5306            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
5307            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5308                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
5309            }
5310            Token::Ident(s)
5311                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
5312            {
5313                let nxt = self.tokens.get(self.pos + 1);
5314                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
5315                    self.pos + 2
5316                } else {
5317                    self.pos + 1
5318                };
5319                after_keyword_followed_by_paren_or_ident_paren(after_after)
5320            }
5321            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
5322                let nxt = self.tokens.get(self.pos + 1);
5323                if !nxt.is_some_and(is_key_or_index_tok) {
5324                    return false;
5325                }
5326                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
5327            }
5328            _ => false,
5329        }
5330    }
5331
5332    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
5333    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
5334    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
5335    /// returns Some(TableConstraint::Index) so the engine builds
5336    /// a real BTree index on the leading column (mysqldump
5337    /// `KEY idx_posts_author (author_id)` shape).
5338    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
5339    /// (the storage layer has no matching AM).
5340    fn parse_mysql_inline_key(
5341        &mut self,
5342    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
5343        // Detect UNIQUE prefix.
5344        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
5345        {
5346            self.advance();
5347            true
5348        } else {
5349            false
5350        };
5351        // Consume FULLTEXT / SPATIAL prefix and record which one
5352        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
5353        // dedicated TableConstraint variant so the engine can
5354        // build a tsvector-GIN; SPATIAL still has no matching
5355        // AM, so it falls back to accept-as-no-op.
5356        let mut is_fulltext = false;
5357        let mut is_spatial = false;
5358        if let Token::Ident(s) = self.peek().clone() {
5359            if s.eq_ignore_ascii_case("fulltext") {
5360                self.advance();
5361                is_fulltext = true;
5362            } else if s.eq_ignore_ascii_case("spatial") {
5363                self.advance();
5364                is_spatial = true;
5365            }
5366        }
5367        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
5368        // (reserved); accept either token shape.
5369        match self.peek() {
5370            Token::Index => {
5371                self.advance();
5372            }
5373            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5374                self.advance();
5375            }
5376            other => {
5377                return Err(self.err(alloc::format!(
5378                    "expected KEY/INDEX in inline index declaration, got {other:?}"
5379                )));
5380            }
5381        }
5382        // Optional index name (an ident before the `(`).
5383        // v7.15.0 — capture the name when present so the engine
5384        // builds the secondary index under the user's chosen
5385        // name (matches mysqldump's `KEY idx_x (col)` shape).
5386        let mut idx_name: Option<String> = None;
5387        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
5388            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
5389        {
5390            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
5391                idx_name = Some(s);
5392            }
5393        }
5394        // Optional `USING BTREE` / `USING HASH` (MySQL).
5395        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5396            self.advance();
5397            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5398                self.advance();
5399            }
5400        }
5401        // Required column list `(col [, col]*)`.
5402        if !matches!(self.peek(), Token::LParen) {
5403            return Err(self.err(alloc::format!(
5404                "expected '(' in inline KEY/INDEX, got {:?}",
5405                self.peek()
5406            )));
5407        }
5408        self.advance();
5409        let mut cols: Vec<String> = Vec::new();
5410        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5411            self.advance();
5412            cols.push(s);
5413            // Skip optional `(length)` per-column prefix.
5414            if matches!(self.peek(), Token::LParen) {
5415                let mut depth = 1usize;
5416                self.advance();
5417                while depth > 0 {
5418                    match self.peek() {
5419                        Token::LParen => depth += 1,
5420                        Token::RParen => depth -= 1,
5421                        Token::Eof => break,
5422                        _ => {}
5423                    }
5424                    self.advance();
5425                }
5426            }
5427            // Skip optional ASC / DESC.
5428            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5429                || matches!(self.peek(), Token::Asc | Token::Desc)
5430            {
5431                self.advance();
5432            }
5433            if matches!(self.peek(), Token::Comma) {
5434                self.advance();
5435                continue;
5436            }
5437            break;
5438        }
5439        if matches!(self.peek(), Token::RParen) {
5440            self.advance();
5441        }
5442        // Trailing options on the inline index — comment / etc.
5443        // Skip until comma or `)`.
5444        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5445            self.advance();
5446        }
5447        if cols.is_empty() {
5448            return Ok(None);
5449        }
5450        if is_unique {
5451            // Carry the captured idx_name on UNIQUE too so future
5452            // engine work can name the underlying BTree
5453            // accordingly; today the unique-constraint installer
5454            // synthesises the name itself, but Display round-trip
5455            // benefits from preserving it.
5456            Ok(Some(crate::ast::TableConstraint::Unique {
5457                name: idx_name,
5458                columns: cols,
5459                nulls_not_distinct: false,
5460            }))
5461        } else if is_fulltext {
5462            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5463            // routes through `TableConstraint::FulltextIndex`;
5464            // the engine builds a tsvector-GIN over each named
5465            // column so MATCH AGAINST gets a real inverted
5466            // index instead of a silently-dropped declaration.
5467            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5468                name: idx_name,
5469                columns: cols,
5470            }))
5471        } else if is_spatial {
5472            // SPG has no native SPATIAL AM. Accept-as-no-op
5473            // (declaration is parsed, but no index is built).
5474            Ok(None)
5475        } else {
5476            // v7.15.0 — plain KEY / INDEX builds a real BTree
5477            // secondary index.
5478            Ok(Some(crate::ast::TableConstraint::Index {
5479                name: idx_name,
5480                columns: cols,
5481            }))
5482        }
5483    }
5484
5485    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5486    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5487    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5488    /// (in any order, separated by whitespace).
5489    fn consume_mysql_table_options(&mut self) {
5490        loop {
5491            // Heuristic: a table option is an ident (or `DEFAULT`
5492            // reserved keyword) followed by `=` and an
5493            // ident / string / integer.
5494            let name_lc = match self.peek().clone() {
5495                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5496                Token::Default => alloc::string::String::from("default"),
5497                _ => break,
5498            };
5499            let known = matches!(
5500                name_lc.as_str(),
5501                "engine"
5502                    | "default"
5503                    | "charset"
5504                    | "collate"
5505                    | "auto_increment"
5506                    | "row_format"
5507                    | "comment"
5508                    | "pack_keys"
5509                    | "stats_persistent"
5510                    | "stats_auto_recalc"
5511                    | "stats_sample_pages"
5512                    | "key_block_size"
5513                    | "tablespace"
5514                    | "min_rows"
5515                    | "max_rows"
5516                    | "checksum"
5517                    | "delay_key_write"
5518                    | "insert_method"
5519                    | "data"
5520                    | "index"
5521                    | "encryption"
5522                    | "compression"
5523            );
5524            if !known {
5525                break;
5526            }
5527            self.advance(); // option name
5528            // `DEFAULT` optional prefix is followed by `CHARSET` /
5529            // `COLLATE`; consume the next ident too.
5530            if name_lc == "default" {
5531                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5532                    self.advance();
5533                }
5534            }
5535            if matches!(self.peek(), Token::Eq) {
5536                self.advance();
5537            }
5538            match self.peek() {
5539                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5540                    self.advance();
5541                }
5542                _ => {}
5543            }
5544        }
5545    }
5546
5547    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5548    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5549    /// sure (otherwise a column literally named `primary` would
5550    /// be mistaken).
5551    fn peek_table_level_pk_start(&self) -> bool {
5552        let cur = self.peek();
5553        let nxt = self.tokens.get(self.pos + 1);
5554        let nxt2 = self.tokens.get(self.pos + 2);
5555        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5556        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5557        let is_lparen = matches!(nxt2, Some(Token::LParen));
5558        is_primary && is_key && is_lparen
5559    }
5560
5561    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5562    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5563    /// (mailrs round-5 G10).
5564    fn peek_table_level_unique_start(&self) -> bool {
5565        let cur = self.peek();
5566        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5567        if !is_unique {
5568            return false;
5569        }
5570        let n1 = self.tokens.get(self.pos + 1);
5571        // Plain `UNIQUE (…)`.
5572        if matches!(n1, Some(Token::LParen)) {
5573            return true;
5574        }
5575        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5576        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5577        if !is_nulls {
5578            return false;
5579        }
5580        let n2 = self.tokens.get(self.pos + 2);
5581        let n3 = self.tokens.get(self.pos + 3);
5582        let n4 = self.tokens.get(self.pos + 4);
5583        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5584        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5585            return true;
5586        }
5587        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5588        if matches!(n2, Some(Token::Not))
5589            && matches!(n3, Some(Token::Distinct))
5590            && matches!(n4, Some(Token::LParen))
5591        {
5592            return true;
5593        }
5594        false
5595    }
5596
5597    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5598        self.advance(); // PRIMARY
5599        self.advance(); // KEY
5600        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5601        Ok(crate::ast::TableConstraint::PrimaryKey {
5602            name: None,
5603            columns,
5604        })
5605    }
5606
5607    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5608        self.advance(); // UNIQUE
5609        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5610        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5611        // is `NULLS DISTINCT` per the SQL standard.
5612        let mut nulls_not_distinct = false;
5613        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5614            let n1 = self.tokens.get(self.pos + 1);
5615            let n2 = self.tokens.get(self.pos + 2);
5616            let is_not = matches!(n1, Some(Token::Not));
5617            let is_distinct = matches!(n2, Some(Token::Distinct));
5618            if is_not && is_distinct {
5619                self.advance(); // NULLS
5620                self.advance(); // NOT
5621                self.advance(); // DISTINCT
5622                nulls_not_distinct = true;
5623            } else if matches!(n1, Some(Token::Distinct)) {
5624                self.advance(); // NULLS
5625                self.advance(); // DISTINCT
5626            }
5627        }
5628        let columns = self.parse_paren_ident_list("UNIQUE")?;
5629        Ok(crate::ast::TableConstraint::Unique {
5630            name: None,
5631            columns,
5632            nulls_not_distinct,
5633        })
5634    }
5635
5636    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5637    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5638    /// expression.
5639    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5640        self.advance(); // CHECK
5641        if !matches!(self.peek(), Token::LParen) {
5642            return Err(self.err(alloc::format!(
5643                "expected '(' after CHECK, got {:?}",
5644                self.peek()
5645            )));
5646        }
5647        self.advance();
5648        let expr = self.parse_expr(0)?;
5649        if !matches!(self.peek(), Token::RParen) {
5650            return Err(self.err(alloc::format!(
5651                "expected ')' to close CHECK predicate, got {:?}",
5652                self.peek()
5653            )));
5654        }
5655        self.advance();
5656        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5657    }
5658
5659    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5660    fn peek_table_level_check_start(&self) -> bool {
5661        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5662    }
5663
5664    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5665    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5666    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5667    /// own CONSTRAINT prefix).
5668    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5669        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5670            return None;
5671        }
5672        // tokens[pos+1] is the constraint name (any ident-like);
5673        // tokens[pos+2] is the kind keyword.
5674        match self.tokens.get(self.pos + 2) {
5675            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5676                Some(NamedTableConstraintKind::Check)
5677            }
5678            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5679                Some(NamedTableConstraintKind::Unique)
5680            }
5681            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5682                Some(NamedTableConstraintKind::PrimaryKey)
5683            }
5684            _ => None,
5685        }
5686    }
5687
5688    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5689        if !matches!(self.peek(), Token::LParen) {
5690            return Err(self.err(alloc::format!(
5691                "expected '(' after {ctx}, got {:?}",
5692                self.peek()
5693            )));
5694        }
5695        self.advance();
5696        let mut out = Vec::new();
5697        loop {
5698            out.push(self.expect_ident_like()?);
5699            match self.peek() {
5700                Token::Comma => {
5701                    self.advance();
5702                }
5703                Token::RParen => {
5704                    self.advance();
5705                    break;
5706                }
5707                other => {
5708                    return Err(self.err(alloc::format!(
5709                        "expected ',' or ')' in {ctx} list, got {other:?}"
5710                    )));
5711                }
5712            }
5713        }
5714        if out.is_empty() {
5715            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5716        }
5717        Ok(out)
5718    }
5719
5720    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5721    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5722    /// table-level FK; a column def never starts with either keyword
5723    /// (column names are not in this reserved set).
5724    fn peek_constraint_or_fk_start(&self) -> bool {
5725        let is_constraint_kw = matches!(
5726            self.peek(),
5727            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5728        );
5729        let is_foreign_kw = matches!(
5730            self.peek(),
5731            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5732        );
5733        is_constraint_kw || is_foreign_kw
5734    }
5735
5736    /// v7.6.0 — parse a table-level FK clause:
5737    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5738    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5739    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5740        let mut name: Option<String> = None;
5741        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5742            self.advance();
5743            name = Some(self.expect_ident_like()?);
5744        }
5745        // `FOREIGN`
5746        match self.advance() {
5747            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5748            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5749        }
5750        // `KEY`
5751        match self.advance() {
5752            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5753            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5754        }
5755        // `(col, col, ...)`
5756        if !matches!(self.peek(), Token::LParen) {
5757            return Err(self.err(format!(
5758                "expected '(' after FOREIGN KEY, got {:?}",
5759                self.peek()
5760            )));
5761        }
5762        self.advance();
5763        let mut columns = Vec::new();
5764        loop {
5765            columns.push(self.expect_ident_like()?);
5766            match self.peek() {
5767                Token::Comma => {
5768                    self.advance();
5769                }
5770                Token::RParen => {
5771                    self.advance();
5772                    break;
5773                }
5774                other => {
5775                    return Err(self.err(format!(
5776                        "expected ',' or ')' in FK column list, got {other:?}"
5777                    )));
5778                }
5779            }
5780        }
5781        if columns.is_empty() {
5782            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5783        }
5784        let (parent_table, parent_columns, on_delete, on_update) =
5785            self.parse_references_tail(columns.len())?;
5786        Ok(ForeignKeyConstraint {
5787            name,
5788            columns,
5789            parent_table,
5790            parent_columns,
5791            on_delete,
5792            on_update,
5793        })
5794    }
5795
5796    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5797    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5798    /// the local column count, used to default the parent column
5799    /// list when omitted (SQL spec: parent's PK is implied).
5800    fn parse_references_tail(
5801        &mut self,
5802        expected_arity: usize,
5803    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5804        match self.advance() {
5805            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5806            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5807        }
5808        let parent_table = self.expect_ident_like()?;
5809        let mut parent_columns: Vec<String> = Vec::new();
5810        if matches!(self.peek(), Token::LParen) {
5811            self.advance();
5812            loop {
5813                parent_columns.push(self.expect_ident_like()?);
5814                match self.peek() {
5815                    Token::Comma => {
5816                        self.advance();
5817                    }
5818                    Token::RParen => {
5819                        self.advance();
5820                        break;
5821                    }
5822                    other => {
5823                        return Err(self.err(format!(
5824                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5825                        )));
5826                    }
5827                }
5828            }
5829        }
5830        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5831            return Err(self.err(format!(
5832                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5833                expected_arity,
5834                parent_columns.len()
5835            )));
5836        }
5837        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5838        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5839        // <action>` / `ON UPDATE <action>` in either order. PG /
5840        // pg_dump emits the timing clause AFTER the ON clauses
5841        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5842        // but the SQL spec allows either order. We loop over
5843        // every possible trailer and dispatch on the next token,
5844        // stopping when nothing matches. Phase 3.1 changes the
5845        // bare DEFERRABLE form from hard-error to accept-as-
5846        // immediate; SPG is single-writer with no deferred-
5847        // constraint window so the runtime semantics are always
5848        // immediate even when INITIALLY DEFERRED is requested.
5849        let mut on_delete = FkAction::Restrict;
5850        let mut on_update = FkAction::Restrict;
5851        let mut seen_on_delete = false;
5852        let mut seen_on_update = false;
5853        loop {
5854            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5855            let before = self.pos;
5856            self.consume_optional_deferrable_clauses()?;
5857            if self.pos != before {
5858                continue;
5859            }
5860            // ON DELETE / ON UPDATE.
5861            if !matches!(self.peek(), Token::On) {
5862                break;
5863            }
5864            self.advance();
5865            let which = self.advance();
5866            let action = self.parse_fk_action()?;
5867            match which {
5868                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5869                    if seen_on_delete {
5870                        return Err(self.err("ON DELETE specified twice".into()));
5871                    }
5872                    seen_on_delete = true;
5873                    on_delete = action;
5874                }
5875                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5876                    if seen_on_update {
5877                        return Err(self.err("ON UPDATE specified twice".into()));
5878                    }
5879                    seen_on_update = true;
5880                    on_update = action;
5881                }
5882                other => {
5883                    return Err(
5884                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5885                    );
5886                }
5887            }
5888        }
5889        Ok((parent_table, parent_columns, on_delete, on_update))
5890    }
5891
5892    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5893    /// NO ACTION`.
5894    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5895        match self.advance() {
5896            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5897            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5898            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5899                Token::Null => Ok(FkAction::SetNull),
5900                Token::Default => Ok(FkAction::SetDefault),
5901                other => Err(self.err(format!(
5902                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5903                ))),
5904            },
5905            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5906                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5907                other => Err(self.err(format!(
5908                    "expected ACTION after NO in FK action, got {other:?}"
5909                ))),
5910            },
5911            other => Err(self.err(format!(
5912                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5913            ))),
5914        }
5915    }
5916
5917    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5918    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5919    fn consume_if_not_exists(&mut self) -> bool {
5920        // `IF` arrives as a bare Ident (we don't reserve it because it
5921        // also appears mid-expression in PG, though we don't support
5922        // those forms yet).
5923        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5924        if !looks_like_if {
5925            return false;
5926        }
5927        // Peek one ahead before committing: only consume IF when it's
5928        // actually `IF NOT EXISTS`.
5929        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5930            return false;
5931        }
5932        if !matches!(
5933            self.tokens.get(self.pos + 2),
5934            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5935        ) {
5936            return false;
5937        }
5938        self.advance(); // IF
5939        self.advance(); // NOT
5940        self.advance(); // EXISTS
5941        true
5942    }
5943
5944    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5945    /// Consumes IF EXISTS as a pair; returns false otherwise
5946    /// without consuming any tokens.
5947    fn consume_if_exists(&mut self) -> bool {
5948        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5949        if !looks_like_if {
5950            return false;
5951        }
5952        if !matches!(
5953            self.tokens.get(self.pos + 1),
5954            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5955        ) {
5956            return false;
5957        }
5958        self.advance(); // IF
5959        self.advance(); // EXISTS
5960        true
5961    }
5962
5963    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5964    /// qualifiers after an index column ref. ASC / DESC are
5965    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5966    /// We accept and discard them since single-column BTree
5967    /// stores rows in natural key order today.
5968    /// v7.24 (round-16 A) — `NULLS FIRST` / `NULLS LAST` after an
5969    /// ORDER BY key. Returns None when absent.
5970    fn parse_optional_nulls_placement(&mut self) -> Result<Option<bool>, ParseError> {
5971        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5972            return Ok(None);
5973        }
5974        self.advance();
5975        match self.advance() {
5976            Token::Ident(s) if s.eq_ignore_ascii_case("first") => Ok(Some(true)),
5977            Token::Ident(s) if s.eq_ignore_ascii_case("last") => Ok(Some(false)),
5978            other => Err(self.err(alloc::format!(
5979                "expected FIRST or LAST after NULLS, got {other:?}"
5980            ))),
5981        }
5982    }
5983
5984    fn consume_optional_index_column_qualifiers(&mut self) {
5985        loop {
5986            match self.peek() {
5987                Token::Asc | Token::Desc => {
5988                    self.advance();
5989                }
5990                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5991                    let look = self.tokens.get(self.pos + 1);
5992                    if matches!(
5993                        look,
5994                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5995                            || k.eq_ignore_ascii_case("last")
5996                    ) {
5997                        self.advance();
5998                        self.advance();
5999                    } else {
6000                        break;
6001                    }
6002                }
6003                _ => break,
6004            }
6005        }
6006    }
6007
6008    fn parse_create_index_stmt_after_create(
6009        &mut self,
6010        is_unique: bool,
6011    ) -> Result<Statement, ParseError> {
6012        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
6013        debug_assert!(matches!(self.peek(), Token::Index));
6014        self.advance();
6015        let if_not_exists = self.consume_if_not_exists();
6016        let name = self.expect_ident_like()?;
6017        if !matches!(self.peek(), Token::On) {
6018            return Err(self.err(format!(
6019                "expected ON after CREATE INDEX <name>, got {:?}",
6020                self.peek()
6021            )));
6022        }
6023        self.advance();
6024        let table = self.expect_ident_like()?;
6025        // Optional `USING <method>` — only recognised method in v2.0 is
6026        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
6027        // ident `using` (we don't promote it to a reserved keyword
6028        // because it isn't reserved anywhere else in our SQL surface).
6029        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6030            self.advance();
6031            let m = self.expect_ident_like()?;
6032            match m.to_ascii_lowercase().as_str() {
6033                "hnsw" => IndexMethod::Hnsw,
6034                "btree" => IndexMethod::BTree,
6035                "brin" => IndexMethod::Brin,
6036                // v7.12.3 — real GIN inverted index over `tsvector`.
6037                // v7.9.26b's `USING gin` → BTree silent fallback is
6038                // gone; the engine validates that the indexed column
6039                // is `tsvector` at CREATE INDEX time.
6040                "gin" => IndexMethod::Gin,
6041                // v7.9.26b — PG `pg_dump` emits `USING gist` /
6042                // `USING spgist` / `USING hash` for their built-in
6043                // AMs that SPG doesn't have a matching
6044                // implementation for; degrade to BTree on the
6045                // leading column so the schema loads + the index
6046                // catalogue stays consistent. Operator pays the
6047                // planner cost only for the queries that would have
6048                // used the specialised AM.
6049                "gist" | "spgist" | "hash" => IndexMethod::BTree,
6050                // v7.11.3 — pgvector ships both `ivfflat` and
6051                // `hnsw`. Customers shouldn't have to choose
6052                // their on-disk index method based on what SPG
6053                // implements; accept `ivfflat` as a synonym for
6054                // `hnsw` so PG schemas using either method drop
6055                // in. The vector distance op (`<->` / `<#>` /
6056                // `<=>`) at query time still picks the metric.
6057                "ivfflat" => IndexMethod::Hnsw,
6058                other => {
6059                    return Err(self.err(alloc::format!(
6060                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
6061                    )));
6062                }
6063            }
6064        } else {
6065            IndexMethod::BTree
6066        };
6067        if !matches!(self.peek(), Token::LParen) {
6068            return Err(self.err(format!(
6069                "expected '(' before indexed column, got {:?}",
6070                self.peek()
6071            )));
6072        }
6073        self.advance();
6074        // v6.8.2 — accept either a bare column ident (legacy) or
6075        // an expression `fn(col, …)` for expression indexes.
6076        // Distinguish by peeking the token *after* the current
6077        // ident: `ident )` is the legacy column-only path;
6078        // anything else triggers the Pratt expression parser.
6079        // (`advance()` uses `mem::replace` to nil out the current
6080        // slot, so we can't save+rewind cleanly — peek-ahead via
6081        // direct index avoids the mutation.)
6082        let mut opclass: Option<String> = None;
6083        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
6084            // Single column with `)` immediately after — fast path.
6085            // v7.9.29 — also: bare column followed by `,` (the
6086            // multi-column form `(a, b, c)`). Without this branch
6087            // the leading ident gets pulled into `parse_expr`
6088            // which then sets `expression = Some(Column(a))` and
6089            // breaks Display round-trip on the multi-column shape.
6090            Token::Ident(s) | Token::QuotedIdent(s)
6091                if matches!(
6092                    self.tokens.get(self.pos + 1),
6093                    Some(Token::RParen | Token::Comma)
6094                ) =>
6095            {
6096                self.advance();
6097                (s, None)
6098            }
6099            // v7.9.22 — single column followed by a pgvector
6100            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
6101            // v7.15.0 — capture the opclass instead of discarding
6102            // it so the engine can dispatch (e.g. `gin_trgm_ops`
6103            // → real trigram-shingle GIN over a TEXT column).
6104            // Vector/HNSW opclasses still take their distance
6105            // metric from the query operator (`<->` / `<#>` /
6106            // `<=>`), so for those callers the opclass stays
6107            // informational.
6108            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
6109            // opclass: `(embedding public.vector_cosine_ops)`. Strip
6110            // the schema and dispatch on the bare opclass, the same
6111            // treatment table/type names get.
6112            Token::Ident(s) | Token::QuotedIdent(s)
6113                if matches!(
6114                    self.tokens.get(self.pos + 1),
6115                    Some(Token::Ident(_) | Token::QuotedIdent(_))
6116                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
6117                    && matches!(
6118                        self.tokens.get(self.pos + 3),
6119                        Some(Token::Ident(op) | Token::QuotedIdent(op))
6120                            if is_vector_opclass_name(op)
6121                    ) =>
6122            {
6123                self.advance(); // column name
6124                self.advance(); // schema qualifier
6125                self.advance(); // dot
6126                let op_tok = self.advance();
6127                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6128                    opclass = Some(op.to_ascii_lowercase());
6129                }
6130                (s, None)
6131            }
6132            Token::Ident(s) | Token::QuotedIdent(s)
6133                if matches!(
6134                    self.tokens.get(self.pos + 1),
6135                    Some(Token::Ident(op) | Token::QuotedIdent(op))
6136                        if is_vector_opclass_name(op)
6137                ) =>
6138            {
6139                self.advance(); // column name
6140                // Capture the opclass token, lower-cased for
6141                // case-insensitive engine dispatch.
6142                let op_tok = self.advance();
6143                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6144                    opclass = Some(op.to_ascii_lowercase());
6145                }
6146                (s, None)
6147            }
6148            Token::Ident(_) | Token::QuotedIdent(_) => {
6149                let key_expr = self.parse_expr(0)?;
6150                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6151                    self.err("expression index key must reference at least one column".into())
6152                })?;
6153                (primary, Some(key_expr))
6154            }
6155            // v7.37.43-T4 — parenthesised expression index key
6156            // `CREATE INDEX … ON t ((payload->'bundle'->>'id'))`.
6157            // PG's CREATE INDEX requires the expression to be in
6158            // its own parens to disambiguate function calls from
6159            // column lists, so this `LParen` is the inner open-paren
6160            // of an expression key. parse_expr handles the recursive
6161            // descent and consumes the matching `RParen`.
6162            Token::LParen => {
6163                let key_expr = self.parse_expr(0)?;
6164                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6165                    self.err("expression index key must reference at least one column".into())
6166                })?;
6167                (primary, Some(key_expr))
6168            }
6169            other => {
6170                return Err(self.err(format!(
6171                    "expected column ident or expression, got {other:?}"
6172                )));
6173            }
6174        };
6175        // v7.9.14 — accept extra comma-separated columns inside
6176        // the index key parens (`CREATE INDEX … (a, b, c)`).
6177        // mailrs F2. Each extra column may carry an optional
6178        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
6179        // — parsed and discarded; SPG doesn't honour direction
6180        // on a BTree index today (column ordering is intrinsic
6181        // to the storage). v7.10 will widen to genuine composite
6182        // index keys.
6183        let mut extra_columns: Vec<String> = Vec::new();
6184        // The leading column may also have ASC/DESC after it.
6185        self.consume_optional_index_column_qualifiers();
6186        while matches!(self.peek(), Token::Comma) {
6187            self.advance();
6188            let extra = self.expect_ident_like()?;
6189            self.consume_optional_index_column_qualifiers();
6190            extra_columns.push(extra);
6191        }
6192        if !matches!(self.peek(), Token::RParen) {
6193            return Err(self.err(format!(
6194                "expected ')' after indexed column / expression, got {:?}",
6195                self.peek()
6196            )));
6197        }
6198        self.advance();
6199        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
6200        // index-only-scan annotation. Bare ident (not a reserved
6201        // keyword) so we test by case-insensitive string match.
6202        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
6203        {
6204            self.advance();
6205            if !matches!(self.peek(), Token::LParen) {
6206                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
6207            }
6208            self.advance();
6209            let mut cols = Vec::new();
6210            loop {
6211                cols.push(self.expect_ident_like()?);
6212                match self.peek() {
6213                    Token::Comma => {
6214                        self.advance();
6215                    }
6216                    Token::RParen => {
6217                        self.advance();
6218                        break;
6219                    }
6220                    other => {
6221                        return Err(self.err(format!(
6222                            "expected ',' or ')' in INCLUDE list, got {other:?}"
6223                        )));
6224                    }
6225                }
6226            }
6227            cols
6228        } else {
6229            Vec::new()
6230        };
6231        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
6232        // storage parameters. pgvector emits `WITH (lists = N)` for
6233        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
6234        // SPG's HNSW picks its own parameters today (tunable via
6235        // env vars), so the WITH clause is informational and dropped.
6236        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
6237            self.advance();
6238            if !matches!(self.peek(), Token::LParen) {
6239                return Err(self.err(format!(
6240                    "expected '(' after WITH in CREATE INDEX, got {:?}",
6241                    self.peek()
6242                )));
6243            }
6244            self.advance();
6245            loop {
6246                if matches!(self.peek(), Token::RParen) {
6247                    self.advance();
6248                    break;
6249                }
6250                // Drain `key = value` or bare `key` tokens.
6251                let _ = self.advance(); // key
6252                if matches!(self.peek(), Token::Eq) {
6253                    self.advance();
6254                    let _ = self.advance(); // value (int / string / ident)
6255                }
6256                match self.peek() {
6257                    Token::Comma => {
6258                        self.advance();
6259                    }
6260                    Token::RParen => {
6261                        self.advance();
6262                        break;
6263                    }
6264                    other => {
6265                        return Err(self.err(format!(
6266                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
6267                        )));
6268                    }
6269                }
6270            }
6271        }
6272        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
6273        let partial_predicate = if matches!(self.peek(), Token::Where) {
6274            self.advance();
6275            Some(self.parse_expr(0)?)
6276        } else {
6277            None
6278        };
6279        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
6280        // sense: uniqueness over an ANN structure has no clean
6281        // semantics. Reject early. (BRIN UNIQUE is similarly
6282        // meaningless — block both.)
6283        if is_unique && !matches!(method, IndexMethod::BTree) {
6284            return Err(self.err(alloc::format!(
6285                "UNIQUE is only supported on BTree indexes, got USING {:?}",
6286                method
6287            )));
6288        }
6289        Ok(Statement::CreateIndex(CreateIndexStatement {
6290            name,
6291            table,
6292            column,
6293            method,
6294            if_not_exists,
6295            included_columns,
6296            partial_predicate,
6297            extra_columns: extra_columns.clone(),
6298            expression,
6299            is_unique,
6300            opclass,
6301        }))
6302    }
6303
6304    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
6305    /// column-level `REFERENCES ...` clause. The trailing FK is
6306    /// normalised into table-level shape (single-element columns +
6307    /// parent_columns) so the engine sees one uniform constraint list.
6308    fn parse_column_def_with_fk(
6309        &mut self,
6310    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
6311        let col = self.parse_column_def()?;
6312        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
6313        let inline_references = matches!(
6314            self.peek(),
6315            Token::Ident(s) if s.eq_ignore_ascii_case("references")
6316        );
6317        if !inline_references {
6318            return Ok((col, None));
6319        }
6320        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
6321        let fk = ForeignKeyConstraint {
6322            name: None,
6323            columns: vec![col.name.clone()],
6324            parent_table,
6325            parent_columns,
6326            on_delete,
6327            on_update,
6328        };
6329        Ok((col, Some(fk)))
6330    }
6331
6332    /// v7.13.0 — parse a column type (consuming the type ident and
6333    /// any trailing parameters / `[]`), without surrounding column
6334    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
6335    /// Returns the resolved `ColumnTypeName` plus implied
6336    /// `(auto_increment, not_null)` flags from PG SERIAL family
6337    /// shorthands — callers that don't expect those (ALTER COLUMN
6338    /// TYPE) can discard them.
6339    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
6340        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
6341        Ok(ty)
6342    }
6343
6344    #[allow(clippy::type_complexity)]
6345    fn parse_type_with_implied_flags(
6346        &mut self,
6347    ) -> Result<
6348        (
6349            ColumnTypeName,
6350            bool,
6351            bool,
6352            Option<String>,
6353            Collation,
6354            bool,
6355            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
6356            // list captured at type-parse time. None for all
6357            // non-ENUM types.
6358            Option<Vec<String>>,
6359            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
6360            // list. Distinct from ENUM (subset semantics).
6361            Option<Vec<String>>,
6362        ),
6363        ParseError,
6364    > {
6365        let mut ty_ident = match self.advance() {
6366            Token::Ident(s) => s,
6367            // v7.37.5 β-P2 — `INTERVAL` lexes as a reserved keyword
6368            // (Token::Interval) since v7.9.25 to drive the `INTERVAL
6369            // '<span>'` literal grammar. As a column type it lands
6370            // here directly; downstream resolution still uses the
6371            // canonical lowercase string.
6372            Token::Interval => "interval".to_string(),
6373            other => {
6374                return Err(ParseError {
6375                    message: format!("expected column type, got {other:?}"),
6376                    token_pos: self.pos.saturating_sub(1),
6377                });
6378            }
6379        };
6380        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
6381        // pg_dump qualifies extension types (`public.vector(1024)`).
6382        // SPG is single-namespace; drop the schema and resolve the
6383        // bare type — same treatment table names already get.
6384        while matches!(self.peek(), Token::Dot) {
6385            self.advance();
6386            ty_ident = self.expect_ident_like()?;
6387        }
6388        let mut implied_auto_increment = false;
6389        let mut implied_not_null = false;
6390        let mut user_type_ref: Option<String> = None;
6391        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
6392        // value list, captured here and bubbled up through the
6393        // ColumnDef so the engine can attach it to the column
6394        // schema (and validate INSERT cells against it).
6395        let mut inline_enum_variants: Option<Vec<String>> = None;
6396        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
6397        let mut inline_set_variants: Option<Vec<String>> = None;
6398        let mut ty = match ty_ident.as_str() {
6399            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
6400            "smallserial" | "serial2" => {
6401                implied_auto_increment = true;
6402                implied_not_null = true;
6403                ColumnTypeName::SmallInt
6404            }
6405            "serial" | "serial4" => {
6406                implied_auto_increment = true;
6407                implied_not_null = true;
6408                ColumnTypeName::Int
6409            }
6410            "bigserial" | "serial8" => {
6411                implied_auto_increment = true;
6412                implied_not_null = true;
6413                ColumnTypeName::BigInt
6414            }
6415            // MySQL flavours we accept by aliasing to the closest SPG
6416            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
6417            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
6418            // 24-bit) → INT. UNSIGNED modifiers are consumed below
6419            // without semantic effect.
6420            "smallint" => {
6421                // v7.14.0 — MySQL display-width on integers
6422                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
6423                // parenthesised number is purely cosmetic — it
6424                // doesn't change storage. Accept + discard.
6425                self.consume_optional_paren_size();
6426                ColumnTypeName::SmallInt
6427            }
6428            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
6429            // canonical encoding for BOOLEAN. Every MySQL driver
6430            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
6431            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
6432            // 4.3 SPG classified TINYINT(1) as SmallInt, which
6433            // gave the customer i16-shaped values where the app
6434            // expected bool — a Tier-A silent type drift on
6435            // mysqldump restores. Now: `TINYINT(1)` → Bool;
6436            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
6437            // stay SmallInt (the legacy width-agnostic path).
6438            "tinyint" => {
6439                let width = self.peek_optional_paren_size_value();
6440                self.consume_optional_paren_size();
6441                if width == Some(1) {
6442                    ColumnTypeName::Bool
6443                } else {
6444                    ColumnTypeName::SmallInt
6445                }
6446            }
6447            "int" | "integer" | "mediumint" => {
6448                self.consume_optional_paren_size();
6449                ColumnTypeName::Int
6450            }
6451            "bigint" => {
6452                self.consume_optional_paren_size();
6453                ColumnTypeName::BigInt
6454            }
6455            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6456            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6457            // (mailrs round-5 G6). Consume the optional `PRECISION`
6458            // tail when the type keyword was `double` / `DOUBLE`.
6459            "float" | "double" | "real" => {
6460                if ty_ident.eq_ignore_ascii_case("double")
6461                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6462                {
6463                    self.advance();
6464                }
6465                ColumnTypeName::Float
6466            }
6467            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6468            "float4" | "float8" => ColumnTypeName::Float,
6469            "text" => ColumnTypeName::Text,
6470            "bool" | "boolean" => ColumnTypeName::Bool,
6471            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6472            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6473            "vector" => {
6474                let dim = self.parse_paren_size("VECTOR")?;
6475                let encoding = self.parse_optional_vector_encoding()?;
6476                ColumnTypeName::Vector { dim, encoding }
6477            }
6478            "numeric" => {
6479                let (precision, scale) = self.parse_optional_numeric_params()?;
6480                ColumnTypeName::Numeric(precision, scale)
6481            }
6482            "date" => ColumnTypeName::Date,
6483            // MySQL's `DATETIME` is the same domain as standard
6484            // `TIMESTAMP` — accept both spellings.
6485            "timestamp" | "datetime" => {
6486                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6487                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6488                // the full form. SPG canonicalises:
6489                //   - WITH TIME ZONE    → Timestamptz
6490                //   - WITHOUT TIME ZONE → Timestamp
6491                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6492                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6493                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6494                {
6495                    self.advance(); // WITH
6496                    self.advance(); // TIME
6497                    self.advance(); // ZONE
6498                    ColumnTypeName::Timestamptz
6499                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6500                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6501                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6502                {
6503                    self.advance(); // WITHOUT
6504                    self.advance(); // TIME
6505                    self.advance(); // ZONE
6506                    ColumnTypeName::Timestamp
6507                } else {
6508                    // Optional `(precision)` parenthesised modifier
6509                    // (PG fractional seconds precision). SPG stores
6510                    // µs always; accept + discard.
6511                    self.consume_optional_paren_size();
6512                    ColumnTypeName::Timestamp
6513                }
6514            }
6515            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6516            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6517            // only PG-wire OID differs.
6518            "timestamptz" => ColumnTypeName::Timestamptz,
6519            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6520            // validation. We accept the JSONB spelling too because
6521            // most PG clients default to it; SPG doesn't distinguish
6522            // the two (no path-operator perf advantage to model).
6523            "json" => ColumnTypeName::Json,
6524            "jsonb" => ColumnTypeName::Jsonb,
6525            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6526            // surface here. Same storage shape; mapping happens at
6527            // the engine side via the ColumnTypeName → DataType
6528            // resolver. Literal forms are handled at coerce_value
6529            // time so the lexer stays untouched.
6530            "bytea" | "bytes" => ColumnTypeName::Bytes,
6531            // v7.17.0 Phase 7 — PG network address types
6532            // v7.17.0 had a Text-backed fallback here for
6533            // `inet` / `cidr` / `macaddr`. v7.37.5 ζ-A promoted
6534            // each to a first-class type; the keywords are
6535            // bound below in the ζ-A block.
6536            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6537            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6538            // arrives in v7.12.1+; the type itself loads here so
6539            // mailrs's `scripts/init-schema.sql` runs unmodified.
6540            "tsvector" => ColumnTypeName::TsVector,
6541            "tsquery" => ColumnTypeName::TsQuery,
6542            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6543            // surface for Django / Rails / Hibernate's default
6544            // PK pattern.
6545            "uuid" => ColumnTypeName::Uuid,
6546            // v7.37.5 β-P2 — PG `INTERVAL` as a column type.
6547            // Storage = three-field {months, days, micros}, catalog
6548            // tag 34, FILE_VERSION 48+, wire OID 1186. Prior to this
6549            // line `INTERVAL` was parser-rejected at CREATE TABLE.
6550            "interval" => ColumnTypeName::Interval,
6551            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6552            // i64 microseconds since 00:00:00. Wire OID 1083.
6553            "time" => ColumnTypeName::Time,
6554            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6555            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6556            "year" => ColumnTypeName::Year,
6557            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6558            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6559            "timetz" => ColumnTypeName::TimeTz,
6560            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6561            // Wire OID 790.
6562            "money" => ColumnTypeName::Money,
6563            // v7.17.0 Phase 3.P0-38 — PG range types.
6564            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6565            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6566            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6567            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6568            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6569            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6570            // v7.37.5 δ — PG 14+ multirange keywords.
6571            "int4multirange" => ColumnTypeName::Multirange(RangeKindAst::Int4),
6572            "int8multirange" => ColumnTypeName::Multirange(RangeKindAst::Int8),
6573            "nummultirange" => ColumnTypeName::Multirange(RangeKindAst::Num),
6574            "tsmultirange" => ColumnTypeName::Multirange(RangeKindAst::Ts),
6575            "tstzmultirange" => ColumnTypeName::Multirange(RangeKindAst::TsTz),
6576            "datemultirange" => ColumnTypeName::Multirange(RangeKindAst::Date),
6577            // v7.37.5 ε — PG geometry scalar keywords.
6578            "point" => ColumnTypeName::Point,
6579            "lseg" => ColumnTypeName::Lseg,
6580            "path" => ColumnTypeName::Path,
6581            "box" => ColumnTypeName::PgBox,
6582            "polygon" => ColumnTypeName::Polygon,
6583            "line" => ColumnTypeName::Line,
6584            "circle" => ColumnTypeName::Circle,
6585            // v7.37.5 ζ-A — network / bit / xml / "char" keywords.
6586            "inet" => ColumnTypeName::Inet,
6587            "cidr" => ColumnTypeName::Cidr,
6588            "macaddr" => ColumnTypeName::Macaddr,
6589            "macaddr8" => ColumnTypeName::Macaddr8,
6590            "bit" => ColumnTypeName::Bit,
6591            "varbit" => ColumnTypeName::BitVarying,
6592            "xml" => ColumnTypeName::Xml,
6593            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6594            "hstore" => ColumnTypeName::Hstore,
6595            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6596            // `ENUM('a','b','c')`. Storage is TEXT; the value
6597            // list lands on `inline_enum_variants` for the
6598            // engine to validate INSERT cells against. Empty
6599            // value list is a parse error (matches MySQL).
6600            "enum" => {
6601                // Expect the opening `(`.
6602                if !matches!(self.peek(), Token::LParen) {
6603                    return Err(self.err(alloc::format!(
6604                        "expected '(' after ENUM, got {:?}",
6605                        self.peek()
6606                    )));
6607                }
6608                self.advance();
6609                let mut variants: Vec<String> = Vec::new();
6610                loop {
6611                    match self.advance() {
6612                        Token::String(s) => variants.push(s),
6613                        other => {
6614                            return Err(self.err(alloc::format!(
6615                                "ENUM(...) expects string literal variants, got {other:?}"
6616                            )));
6617                        }
6618                    }
6619                    match self.peek() {
6620                        Token::Comma => {
6621                            self.advance();
6622                            continue;
6623                        }
6624                        Token::RParen => {
6625                            self.advance();
6626                            break;
6627                        }
6628                        other => {
6629                            return Err(self.err(alloc::format!(
6630                                "expected ',' or ')' in ENUM(...), got {other:?}"
6631                            )));
6632                        }
6633                    }
6634                }
6635                if variants.is_empty() {
6636                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6637                }
6638                inline_enum_variants = Some(variants);
6639                // Storage is plain TEXT; the variant list lives on
6640                // the ColumnSchema side.
6641                ColumnTypeName::Text
6642            }
6643            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6644            // `SET('a','b','c')`. Same parse shape as ENUM;
6645            // semantics differ (subset rather than pick-one).
6646            "set" => {
6647                if !matches!(self.peek(), Token::LParen) {
6648                    return Err(self.err(alloc::format!(
6649                        "expected '(' after SET, got {:?}",
6650                        self.peek()
6651                    )));
6652                }
6653                self.advance();
6654                let mut variants: Vec<String> = Vec::new();
6655                loop {
6656                    match self.advance() {
6657                        Token::String(s) => variants.push(s),
6658                        other => {
6659                            return Err(self.err(alloc::format!(
6660                                "SET(...) expects string literal variants, got {other:?}"
6661                            )));
6662                        }
6663                    }
6664                    match self.peek() {
6665                        Token::Comma => {
6666                            self.advance();
6667                            continue;
6668                        }
6669                        Token::RParen => {
6670                            self.advance();
6671                            break;
6672                        }
6673                        other => {
6674                            return Err(self.err(alloc::format!(
6675                                "expected ',' or ')' in SET(...), got {other:?}"
6676                            )));
6677                        }
6678                    }
6679                }
6680                if variants.is_empty() {
6681                    return Err(self.err("SET(...) must declare at least one variant".into()));
6682                }
6683                inline_set_variants = Some(variants);
6684                ColumnTypeName::Text
6685            }
6686            _other => {
6687                // v7.17.0 Phase 1.4 — unknown ident → defer
6688                // resolution to the engine. Stored as Text in
6689                // ColumnTypeName + the original name carried as
6690                // `user_type_ref` so CREATE TABLE can look up
6691                // user-defined enum / domain types.
6692                user_type_ref = Some(ty_ident.clone());
6693                ColumnTypeName::Text
6694            }
6695        };
6696        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6697        // right after the type keyword. Pre-4.4 SPG consumed +
6698        // discarded the keyword, leaving a customer column
6699        // declared `id INT UNSIGNED NOT NULL` silently accepting
6700        // negative values — a Tier-A correctness drift where
6701        // application invariants (auto-increment-IDs never
6702        // negative) silently broke on cutover. Now: capture as
6703        // a column flag, persist on the schema, enforce at
6704        // INSERT / UPDATE time.
6705        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6706        {
6707            self.advance();
6708            true
6709        } else {
6710            false
6711        };
6712        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6713        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6714        // stores text as UTF-8 always so CHARACTER SET is still a
6715        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6716        // name: it gets classified into a `Collation` variant the
6717        // engine consults at WHERE-eval time. PG `default` /
6718        // `pg_catalog.default` / `C` / `POSIX` collations all
6719        // resolve to `Binary` (the prior behaviour); `_ci` /
6720        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6721        // The schema-qualifier form (`pg_catalog.default`) lexes
6722        // as `Ident '.' Ident` — peek for the `.` and consume both
6723        // halves so it's treated as one collation name. PG's
6724        // `IDENT.IDENT` collation form (which can appear here) is
6725        // resolved by Collation::from_collation_name on the bare
6726        // identifier after the dot.
6727        let mut collation = Collation::Binary;
6728        loop {
6729            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6730                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6731            {
6732                self.advance(); // CHARACTER
6733                self.advance(); // SET
6734                if matches!(
6735                    self.peek(),
6736                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6737                ) {
6738                    self.advance();
6739                }
6740                continue;
6741            }
6742            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6743                self.advance(); // COLLATE
6744                // Accept Ident / QuotedIdent / String AND the
6745                // keyword-tokenised `Default` (PG `pg_catalog.default`
6746                // and bare `DEFAULT` collation names — `default` is a
6747                // reserved word so the lexer hands back Token::Default
6748                // not Token::Ident).
6749                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6750                    match this.peek().clone() {
6751                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6752                            this.advance();
6753                            Some(s)
6754                        }
6755                        Token::Default => {
6756                            this.advance();
6757                            Some(alloc::string::String::from("default"))
6758                        }
6759                        _ => None,
6760                    }
6761                };
6762                let raw = if let Some(head) = read_collation_atom(self) {
6763                    // Schema-qualified PG form: `pg_catalog.default`.
6764                    if matches!(self.peek(), Token::Dot) {
6765                        self.advance();
6766                        let tail = read_collation_atom(self).unwrap_or_default();
6767                        alloc::format!("{head}.{tail}")
6768                    } else {
6769                        head
6770                    }
6771                } else {
6772                    alloc::string::String::new()
6773                };
6774                if !raw.is_empty() {
6775                    let parsed = Collation::from_collation_name(&raw);
6776                    // Last COLLATE clause wins, but `Binary` from a
6777                    // bare keyword like `default` should not
6778                    // silently downgrade a stronger one set earlier
6779                    // on the same column. v7.17 only ships one
6780                    // non-Binary variant so a simple OR is enough.
6781                    if parsed != Collation::Binary {
6782                        collation = parsed;
6783                    }
6784                }
6785                continue;
6786            }
6787            break;
6788        }
6789        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6790        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6791        // so we reject other base types here. mailrs uses TEXT[]
6792        // for labels / addresses / message-on-thread.
6793        if matches!(self.peek(), Token::LBracket) {
6794            self.advance();
6795            if !matches!(self.peek(), Token::RBracket) {
6796                return Err(self.err(alloc::format!(
6797                    "TEXT[] takes no dimension; got {:?}",
6798                    self.peek()
6799                )));
6800            }
6801            self.advance();
6802            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6803            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6804            // still error here.
6805            ty = match ty {
6806                ColumnTypeName::Text => ColumnTypeName::TextArray,
6807                ColumnTypeName::Int => ColumnTypeName::IntArray,
6808                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6809                // v7.37.5 β-P4 — INTERVAL[] via the same postfix
6810                // `[]` grammar. Wire OID 1187.
6811                ColumnTypeName::Interval => ColumnTypeName::IntervalArray,
6812                // v7.37.5 γ — full PG array-of-scalar family.
6813                ColumnTypeName::Bool => ColumnTypeName::BoolArray,
6814                ColumnTypeName::SmallInt => ColumnTypeName::SmallIntArray,
6815                ColumnTypeName::Float => ColumnTypeName::FloatArray,
6816                // NUMERIC(p, s) loses its precision params at the
6817                // array level (matches PG: `NUMERIC[]` is untyped,
6818                // per-element precision flows through values).
6819                ColumnTypeName::Numeric(_, _) => ColumnTypeName::NumericArray,
6820                ColumnTypeName::Date => ColumnTypeName::DateArray,
6821                ColumnTypeName::Timestamp => ColumnTypeName::TimestampArray,
6822                ColumnTypeName::Timestamptz => ColumnTypeName::TimestamptzArray,
6823                ColumnTypeName::Uuid => ColumnTypeName::UuidArray,
6824                ColumnTypeName::Json => ColumnTypeName::JsonArray,
6825                ColumnTypeName::Jsonb => ColumnTypeName::JsonbArray,
6826                ColumnTypeName::Bytes => ColumnTypeName::BytesArray,
6827                // VARCHAR(n)[] / CHAR(n)[] drop the length cap at
6828                // the array level (matches PG semantics where the
6829                // element precision is per-row, not column-wide).
6830                ColumnTypeName::Varchar(_) => ColumnTypeName::VarcharArray,
6831                ColumnTypeName::Char(_) => ColumnTypeName::CharArray,
6832                // v7.37.5 ζ-A — MONEY[] (OID 791) ship-triage
6833                // follow-up.
6834                ColumnTypeName::Money => ColumnTypeName::MoneyArray,
6835                other => {
6836                    return Err(self.err(alloc::format!("{other:?}[] not yet supported")));
6837                }
6838            };
6839            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6840            // for INT/TEXT/BIGINT. Anything else is an error.
6841            if matches!(self.peek(), Token::LBracket) {
6842                self.advance();
6843                if !matches!(self.peek(), Token::RBracket) {
6844                    return Err(self.err(alloc::format!(
6845                        "TYPE[][] second dimension takes no size; got {:?}",
6846                        self.peek()
6847                    )));
6848                }
6849                self.advance();
6850                ty = match ty {
6851                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6852                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6853                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6854                    other => {
6855                        return Err(self.err(alloc::format!(
6856                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6857                             TEXT[][] only; got {other:?}"
6858                        )));
6859                    }
6860                };
6861            }
6862        }
6863        Ok((
6864            ty,
6865            implied_auto_increment,
6866            implied_not_null,
6867            user_type_ref,
6868            collation,
6869            is_unsigned,
6870            inline_enum_variants,
6871            inline_set_variants,
6872        ))
6873    }
6874
6875    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6876        // v7.20 — PG reserves the table-constraint keywords, so a
6877        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6878        // malformed constraint clause (e.g. `UNIQUE a` missing its
6879        // parens), not a column named "unique". Since v7.17's
6880        // unknown-type leniency (`user_type_ref`) such a clause
6881        // would otherwise parse as a column with a user-defined
6882        // type — silently accepting invalid DDL. Quoted
6883        // identifiers ("unique" / `unique`) remain valid names.
6884        if let Token::Ident(s) = self.peek()
6885            && [
6886                "unique",
6887                "primary",
6888                "foreign",
6889                "constraint",
6890                "check",
6891                "references",
6892                "exclude",
6893            ]
6894            .iter()
6895            .any(|kw| s.eq_ignore_ascii_case(kw))
6896        {
6897            return Err(self.err(alloc::format!(
6898                "unexpected reserved keyword '{s}' at start of column definition \
6899                 (malformed table constraint?)"
6900            )));
6901        }
6902        let name = self.expect_ident_like()?;
6903        let (
6904            ty,
6905            implied_auto_increment,
6906            implied_not_null,
6907            user_type_ref,
6908            collation,
6909            is_unsigned,
6910            inline_enum_variants,
6911            inline_set_variants,
6912        ) = self.parse_type_with_implied_flags()?;
6913        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6914        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6915        // each at most once.
6916        let mut default: Option<Expr> = None;
6917        let mut nullable = !implied_not_null;
6918        let mut nullability_seen = implied_not_null;
6919        let mut auto_increment = implied_auto_increment;
6920        let mut is_primary_key = false;
6921        let mut is_unique = false;
6922        let mut check: Option<Expr> = None;
6923        let mut on_update_runtime: Option<Expr> = None;
6924        let mut generated_stored_expr: Option<Box<Expr>> = None;
6925        loop {
6926            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
6927            // not-null constraints by name and pg_dump emits them
6928            // inline: `id bigint CONSTRAINT contacts_id_not_null1
6929            // NOT NULL`. Accept and discard the name; whatever
6930            // constraint follows is parsed by the arms below.
6931            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
6932                self.advance();
6933                let _name = self.expect_ident_like()?;
6934                continue;
6935            }
6936            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
6937            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
6938            // the modern replacement for SERIAL in hand-written
6939            // schemas). Both flavours map onto the auto-increment
6940            // machinery — SPG's serial semantics ≈ BY DEFAULT;
6941            // ALWAYS's reject-explicit-values nuance is documented
6942            // leniency. Generated EXPRESSION columns
6943            // (`AS (expr) STORED`) are not supported: error loudly
6944            // instead of silently storing NULLs.
6945            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
6946                self.advance();
6947                match self.peek().clone() {
6948                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
6949                        self.advance();
6950                    }
6951                    // `BY` is a reserved keyword token (GROUP BY).
6952                    Token::By => {
6953                        self.advance();
6954                        if !matches!(self.peek(), Token::Default) {
6955                            return Err(self.err(alloc::format!(
6956                                "expected DEFAULT after GENERATED BY, got {:?}",
6957                                self.peek()
6958                            )));
6959                        }
6960                        self.advance();
6961                    }
6962                    other => {
6963                        return Err(self.err(alloc::format!(
6964                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
6965                        )));
6966                    }
6967                }
6968                if !matches!(self.peek(), Token::As) {
6969                    return Err(self.err(alloc::format!(
6970                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
6971                        self.peek()
6972                    )));
6973                }
6974                self.advance();
6975                // v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS
6976                // ( <expr> ) STORED` stored computed-column. The
6977                // expression is captured for the engine to recompute
6978                // on every INSERT / UPDATE. v7.37.7 accepts the
6979                // STORED keyword only; PG also has VIRTUAL, which
6980                // v7.37.7 carves out (sentori only uses STORED).
6981                if matches!(self.peek(), Token::LParen) {
6982                    self.advance();
6983                    let expr = self.parse_expr(0)?;
6984                    if !matches!(self.peek(), Token::RParen) {
6985                        return Err(self.err(alloc::format!(
6986                            "expected ')' after GENERATED ALWAYS AS (<expr>), got {:?}",
6987                            self.peek()
6988                        )));
6989                    }
6990                    self.advance();
6991                    let stored = match self.peek() {
6992                        Token::Ident(s) | Token::QuotedIdent(s)
6993                            if s.eq_ignore_ascii_case("stored") =>
6994                        {
6995                            self.advance();
6996                            true
6997                        }
6998                        Token::Ident(s) | Token::QuotedIdent(s)
6999                            if s.eq_ignore_ascii_case("virtual") =>
7000                        {
7001                            return Err(self.err(
7002                                "GENERATED ALWAYS AS (expr) VIRTUAL is not supported \
7003                                 at v7.37.7; use STORED"
7004                                    .into(),
7005                            ));
7006                        }
7007                        other => {
7008                            return Err(self.err(alloc::format!(
7009                                "expected STORED after GENERATED ALWAYS AS (<expr>), \
7010                                 got {other:?}"
7011                            )));
7012                        }
7013                    };
7014                    let _ = stored; // currently STORED-only; flag reserved for VIRTUAL.
7015                    generated_stored_expr = Some(Box::new(expr));
7016                    continue;
7017                }
7018                self.expect_keyword_ident("identity")?;
7019                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
7020                // consume the balanced parens and discard (SPG's
7021                // auto-increment is max+1-scan based).
7022                if matches!(self.peek(), Token::LParen) {
7023                    let mut depth = 0usize;
7024                    loop {
7025                        match self.advance() {
7026                            Token::LParen => depth += 1,
7027                            Token::RParen => {
7028                                depth -= 1;
7029                                if depth == 0 {
7030                                    break;
7031                                }
7032                            }
7033                            Token::Eof => {
7034                                return Err(self.err(
7035                                    "unterminated sequence-options parens after IDENTITY".into(),
7036                                ));
7037                            }
7038                            _ => {}
7039                        }
7040                    }
7041                }
7042                auto_increment = true;
7043                // PG identity columns are implicitly NOT NULL.
7044                nullable = false;
7045                continue;
7046            }
7047            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
7048            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
7049            // is accepted today. The "ON" token is an Ident
7050            // (not reserved) — peek before consuming.
7051            if matches!(self.peek(), Token::On)
7052                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
7053            {
7054                self.advance(); // ON
7055                self.advance(); // update
7056                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
7057                let next = self.peek().clone();
7058                match next {
7059                    Token::Ident(s) | Token::QuotedIdent(s)
7060                        if s.eq_ignore_ascii_case("current_timestamp") =>
7061                    {
7062                        self.advance();
7063                        // Optional `(N)` precision.
7064                        if matches!(self.peek(), Token::LParen) {
7065                            self.advance();
7066                            if !matches!(self.peek(), Token::Integer(_)) {
7067                                return Err(self.err(alloc::format!(
7068                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
7069                                    self.peek()
7070                                )));
7071                            }
7072                            self.advance();
7073                            if !matches!(self.peek(), Token::RParen) {
7074                                return Err(self.err(alloc::format!(
7075                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
7076                                    self.peek()
7077                                )));
7078                            }
7079                            self.advance();
7080                        }
7081                        on_update_runtime = Some(Expr::FunctionCall {
7082                            name: "now".into(),
7083                            args: Vec::new(),
7084                        });
7085                        continue;
7086                    }
7087                    other => {
7088                        return Err(self.err(alloc::format!(
7089                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
7090                        )));
7091                    }
7092                }
7093            }
7094            if matches!(self.peek(), Token::Default) {
7095                if default.is_some() {
7096                    return Err(self.err("DEFAULT specified twice".into()));
7097                }
7098                self.advance();
7099                default = Some(self.parse_expr(0)?);
7100                continue;
7101            }
7102            if matches!(self.peek(), Token::Not) {
7103                if nullability_seen {
7104                    return Err(self.err("NOT NULL specified twice".into()));
7105                }
7106                self.advance();
7107                if !matches!(self.peek(), Token::Null) {
7108                    return Err(self.err(format!(
7109                        "expected NULL after NOT in column def, got {:?}",
7110                        self.peek()
7111                    )));
7112                }
7113                self.advance();
7114                nullable = false;
7115                nullability_seen = true;
7116                continue;
7117            }
7118            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
7119            // "this column is nullable" marker (the default in
7120            // standard SQL anyway). mysqldump emits it routinely
7121            // (`col TYPE NULL DEFAULT NULL` for nullable
7122            // timestamps etc). Accept + no-op.
7123            if matches!(self.peek(), Token::Null) {
7124                if nullability_seen && !nullable {
7125                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
7126                }
7127                self.advance();
7128                nullable = true;
7129                nullability_seen = true;
7130                continue;
7131            }
7132            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
7133            // arrives as a bare Ident. Match either, case-insensitive.
7134            if let Token::Ident(s) = self.peek()
7135                && (s.eq_ignore_ascii_case("auto_increment")
7136                    || s.eq_ignore_ascii_case("autoincrement"))
7137            {
7138                if auto_increment {
7139                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
7140                }
7141                self.advance();
7142                auto_increment = true;
7143                continue;
7144            }
7145            // v7.9.13 — inline `PRIMARY KEY` column constraint
7146            // (mailrs F1). Implies `NOT NULL`. The engine creates
7147            // a BTree index for the PK column at CREATE TABLE time
7148            // so FK parent-side index lookups resolve.
7149            if let Token::Ident(s) = self.peek()
7150                && s.eq_ignore_ascii_case("primary")
7151            {
7152                if is_primary_key {
7153                    return Err(self.err("PRIMARY KEY specified twice".into()));
7154                }
7155                // Peek-ahead for the required `KEY` token.
7156                let next = self.tokens.get(self.pos + 1);
7157                let next_is_key = matches!(
7158                    next,
7159                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
7160                );
7161                if !next_is_key {
7162                    return Err(self.err(format!(
7163                        "expected KEY after PRIMARY in column def, got {:?}",
7164                        next
7165                    )));
7166                }
7167                self.advance(); // PRIMARY
7168                self.advance(); // KEY
7169                is_primary_key = true;
7170                if nullability_seen && nullable {
7171                    return Err(self.err(
7172                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
7173                    ));
7174                }
7175                nullable = false;
7176                nullability_seen = true;
7177                continue;
7178            }
7179            // v7.13.0 — inline `UNIQUE` column constraint
7180            // (mailrs round-5 G2). Fold into a single-column
7181            // table-level UNIQUE at CREATE TABLE post-process time.
7182            if let Token::Ident(s) = self.peek()
7183                && s.eq_ignore_ascii_case("unique")
7184            {
7185                if is_unique {
7186                    return Err(self.err("UNIQUE specified twice".into()));
7187                }
7188                self.advance();
7189                is_unique = true;
7190                continue;
7191            }
7192            // v7.13.0 — inline `CHECK (<expr>)` column constraint
7193            // (mailrs round-5 G3). PG semantics: column-level
7194            // CHECK is equivalent to a table-level CHECK. Multiple
7195            // inline CHECKs on the same column AND together.
7196            if let Token::Ident(s) = self.peek()
7197                && s.eq_ignore_ascii_case("check")
7198            {
7199                self.advance();
7200                if !matches!(self.peek(), Token::LParen) {
7201                    return Err(self.err(alloc::format!(
7202                        "expected '(' after CHECK in column def, got {:?}",
7203                        self.peek()
7204                    )));
7205                }
7206                self.advance();
7207                let pred = self.parse_expr(0)?;
7208                if !matches!(self.peek(), Token::RParen) {
7209                    return Err(self.err(alloc::format!(
7210                        "expected ')' to close CHECK predicate, got {:?}",
7211                        self.peek()
7212                    )));
7213                }
7214                self.advance();
7215                check = Some(match check.take() {
7216                    Some(prev) => Expr::Binary {
7217                        op: BinOp::And,
7218                        lhs: Box::new(prev),
7219                        rhs: Box::new(pred),
7220                    },
7221                    None => pred,
7222                });
7223                continue;
7224            }
7225            break;
7226        }
7227        Ok(ColumnDef {
7228            name,
7229            ty,
7230            nullable,
7231            default,
7232            auto_increment,
7233            is_primary_key,
7234            is_unique,
7235            check,
7236            user_type_ref,
7237            on_update_runtime,
7238            collation,
7239            is_unsigned,
7240            inline_enum_variants,
7241            inline_set_variants,
7242            generated_stored_expr,
7243        })
7244    }
7245
7246    /// `NUMERIC` may appear without parameters, with one (precision
7247    /// only, scale=0), or with both. Returns `(precision, scale)` with
7248    /// 0 = unspecified for the bare form.
7249    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
7250        if !matches!(self.peek(), Token::LParen) {
7251            // Bare `NUMERIC` — PG treats this as "unlimited precision";
7252            // we surface it as precision=0 to mean "unconstrained" so
7253            // the engine doesn't need a separate variant.
7254            return Ok((0, 0));
7255        }
7256        self.advance();
7257        let precision = match self.advance() {
7258            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
7259            other => {
7260                return Err(ParseError {
7261                    message: format!(
7262                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
7263                    ),
7264                    token_pos: self.pos.saturating_sub(1),
7265                });
7266            }
7267        };
7268        let scale = if matches!(self.peek(), Token::Comma) {
7269            self.advance();
7270            match self.advance() {
7271                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
7272                    u8::try_from(n).expect("range-checked")
7273                }
7274                other => {
7275                    return Err(ParseError {
7276                        message: format!(
7277                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
7278                        ),
7279                        token_pos: self.pos.saturating_sub(1),
7280                    });
7281                }
7282            }
7283        } else {
7284            0
7285        };
7286        if !matches!(self.peek(), Token::RParen) {
7287            return Err(self.err(format!(
7288                "expected ')' to close NUMERIC params, got {:?}",
7289                self.peek()
7290            )));
7291        }
7292        self.advance();
7293        Ok((precision, scale))
7294    }
7295
7296    /// Parse `(N)` where `N` is a positive integer literal — used by the
7297    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
7298    /// for the error message.
7299    /// v6.0.1: parse the optional `USING <encoding>` clause that
7300    /// follows `VECTOR(N)` in a column definition. Missing clause
7301    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
7302    /// ident → `ParseError` listing the encodings recognised today.
7303    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
7304        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
7305            return Ok(VecEncoding::F32);
7306        }
7307        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
7308        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
7309        // consume the token when the very next token is a known
7310        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
7311        // `USING` for the caller — it's the rewrite-expression form.
7312        let n1 = self.tokens.get(self.pos + 1);
7313        let next_is_encoding = matches!(
7314            n1,
7315            Some(Token::Ident(s))
7316                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
7317        );
7318        if !next_is_encoding {
7319            return Ok(VecEncoding::F32);
7320        }
7321        self.advance();
7322        let enc_ident = match self.advance() {
7323            Token::Ident(s) => s,
7324            other => {
7325                return Err(self.err(format!(
7326                    "expected vector encoding after USING, got {other:?}"
7327                )));
7328            }
7329        };
7330        match enc_ident.to_ascii_lowercase().as_str() {
7331            "sq8" => Ok(VecEncoding::Sq8),
7332            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
7333            // binary16 per-element storage.
7334            "half" => Ok(VecEncoding::F16),
7335            other => Err(self.err(format!(
7336                "unknown vector encoding {other:?}; supported: SQ8, HALF"
7337            ))),
7338        }
7339    }
7340
7341    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
7342    /// without consuming it. Returns `Some(N)` when the next
7343    /// tokens are `( <int> )`; None otherwise. Used by the
7344    /// TINYINT classifier to decide whether to map to Bool or
7345    /// SmallInt.
7346    fn peek_optional_paren_size_value(&self) -> Option<i64> {
7347        if !matches!(self.peek(), Token::LParen) {
7348            return None;
7349        }
7350        let next = self.tokens.get(self.pos + 1)?;
7351        let n = match next {
7352            Token::Integer(n) => *n,
7353            _ => return None,
7354        };
7355        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
7356            return None;
7357        }
7358        Some(n)
7359    }
7360
7361    /// v7.14.0 — consume an optional MySQL display-width
7362    /// parenthesised number after an integer type, returning
7363    /// nothing. `TINYINT(1)` etc.
7364    fn consume_optional_paren_size(&mut self) {
7365        if !matches!(self.peek(), Token::LParen) {
7366            return;
7367        }
7368        self.advance();
7369        // Skip until matching RParen (allow nested or any tokens).
7370        let mut depth = 1usize;
7371        while depth > 0 {
7372            match self.peek() {
7373                Token::LParen => depth += 1,
7374                Token::RParen => depth -= 1,
7375                Token::Eof => return,
7376                _ => {}
7377            }
7378            self.advance();
7379        }
7380    }
7381
7382    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
7383        if !matches!(self.peek(), Token::LParen) {
7384            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
7385        }
7386        self.advance();
7387        let n = match self.advance() {
7388            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
7389                message: format!("{label} size too large: {n}"),
7390                token_pos: self.pos.saturating_sub(1),
7391            })?,
7392            other => {
7393                return Err(ParseError {
7394                    message: format!("expected positive integer {label} size, got {other:?}"),
7395                    token_pos: self.pos.saturating_sub(1),
7396                });
7397            }
7398        };
7399        if !matches!(self.peek(), Token::RParen) {
7400            return Err(self.err(format!(
7401                "expected ')' after {label} size, got {:?}",
7402                self.peek()
7403            )));
7404        }
7405        self.advance();
7406        Ok(n)
7407    }
7408
7409    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
7410        debug_assert!(matches!(self.peek(), Token::Insert));
7411        self.advance();
7412        if !matches!(self.peek(), Token::Into) {
7413            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
7414        }
7415        self.advance();
7416        let table = self.expect_ident_like()?;
7417        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
7418        let columns = if matches!(self.peek(), Token::LParen) {
7419            self.advance();
7420            let mut names = Vec::new();
7421            loop {
7422                names.push(self.expect_ident_like()?);
7423                match self.peek() {
7424                    Token::Comma => {
7425                        self.advance();
7426                    }
7427                    Token::RParen => {
7428                        self.advance();
7429                        break;
7430                    }
7431                    other => {
7432                        return Err(self.err(format!(
7433                            "expected ',' or ')' in INSERT column list, got {other:?}"
7434                        )));
7435                    }
7436                }
7437            }
7438            Some(names)
7439        } else {
7440            None
7441        };
7442        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
7443        // round-5 G4). Dispatch on VALUES vs SELECT.
7444        if matches!(self.peek(), Token::Select) {
7445            let select_stmt = match self.parse_select_stmt()? {
7446                Statement::Select(s) => s,
7447                other => {
7448                    return Err(self.err(alloc::format!(
7449                        "expected SELECT after INSERT INTO ... target, got {other:?}"
7450                    )));
7451                }
7452            };
7453            let on_conflict = self.parse_optional_on_conflict()?;
7454            let returning = self.parse_optional_returning()?;
7455            return Ok(Statement::Insert(InsertStatement {
7456                ctes: Vec::new(),
7457                table,
7458                columns,
7459                rows: Vec::new(),
7460                select_source: Some(Box::new(select_stmt)),
7461                on_conflict,
7462                returning,
7463            }));
7464        }
7465        if !matches!(self.peek(), Token::Values) {
7466            return Err(self.err(format!(
7467                "expected VALUES or SELECT after table name, got {:?}",
7468                self.peek()
7469            )));
7470        }
7471        self.advance();
7472        if !matches!(self.peek(), Token::LParen) {
7473            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
7474        }
7475        let mut rows = Vec::new();
7476        loop {
7477            // Each iteration consumes one `(expr, expr, …)` tuple.
7478            if !matches!(self.peek(), Token::LParen) {
7479                return Err(self.err(format!(
7480                    "expected '(' for next VALUES tuple, got {:?}",
7481                    self.peek()
7482                )));
7483            }
7484            self.advance();
7485            let mut tuple = Vec::new();
7486            loop {
7487                tuple.push(self.parse_expr(0)?);
7488                match self.peek() {
7489                    Token::Comma => {
7490                        self.advance();
7491                    }
7492                    Token::RParen => {
7493                        self.advance();
7494                        break;
7495                    }
7496                    other => {
7497                        return Err(self.err(format!(
7498                            "expected ',' or ')' in VALUES tuple, got {other:?}"
7499                        )));
7500                    }
7501                }
7502            }
7503            if tuple.is_empty() {
7504                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
7505            }
7506            rows.push(tuple);
7507            // Continue with comma-separated tuples.
7508            if matches!(self.peek(), Token::Comma) {
7509                self.advance();
7510            } else {
7511                break;
7512            }
7513        }
7514        let on_conflict = self.parse_optional_on_conflict()?;
7515        let returning = self.parse_optional_returning()?;
7516        Ok(Statement::Insert(InsertStatement {
7517            ctes: Vec::new(),
7518            table,
7519            columns,
7520            rows,
7521            select_source: None,
7522            on_conflict,
7523            returning,
7524        }))
7525    }
7526
7527    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
7528    /// clause sitting between the INSERT body and the trailing
7529    /// RETURNING. All keywords come in as bare idents; `ON` is
7530    /// a reserved Token though.
7531    fn parse_optional_on_conflict(
7532        &mut self,
7533    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7534        if !matches!(self.peek(), Token::On) {
7535            return Ok(None);
7536        }
7537        // Peek further: we want exactly "ON CONFLICT ...". If the
7538        // next ident isn't "conflict", let some other parser handle.
7539        let next_is_conflict = matches!(
7540            self.tokens.get(self.pos + 1),
7541            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7542        );
7543        if !next_is_conflict {
7544            return Ok(None);
7545        }
7546        self.advance(); // ON
7547        self.advance(); // CONFLICT
7548        // Optional `(col [, col]*)` target list.
7549        let mut target_columns: Vec<String> = Vec::new();
7550        if matches!(self.peek(), Token::LParen) {
7551            self.advance();
7552            loop {
7553                target_columns.push(self.expect_ident_like()?);
7554                match self.peek() {
7555                    Token::Comma => {
7556                        self.advance();
7557                    }
7558                    Token::RParen => {
7559                        self.advance();
7560                        break;
7561                    }
7562                    other => {
7563                        return Err(self.err(alloc::format!(
7564                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7565                        )));
7566                    }
7567                }
7568            }
7569        }
7570        // Required `DO`.
7571        match self.advance() {
7572            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7573            other => {
7574                return Err(self.err(alloc::format!(
7575                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7576                )));
7577            }
7578        }
7579        // Action: NOTHING | UPDATE SET …
7580        let action = match self.advance() {
7581            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7582                crate::ast::OnConflictAction::Nothing
7583            }
7584            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7585                self.parse_on_conflict_update_action()?
7586            }
7587            other => {
7588                return Err(self.err(alloc::format!(
7589                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7590                )));
7591            }
7592        };
7593        Ok(Some(crate::ast::OnConflictClause {
7594            target_columns,
7595            action,
7596        }))
7597    }
7598
7599    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7600    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7601    /// consumed `UPDATE`.
7602    fn parse_on_conflict_update_action(
7603        &mut self,
7604    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7605        // `SET`
7606        match self.advance() {
7607            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7608            other => {
7609                return Err(self.err(alloc::format!(
7610                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7611                )));
7612            }
7613        }
7614        let mut assignments: Vec<(String, Expr)> = Vec::new();
7615        loop {
7616            let col = self.expect_ident_like()?;
7617            if !matches!(self.peek(), Token::Eq) {
7618                return Err(self.err(alloc::format!(
7619                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7620                    self.peek()
7621                )));
7622            }
7623            self.advance();
7624            let value = self.parse_expr(0)?;
7625            assignments.push((col, value));
7626            if matches!(self.peek(), Token::Comma) {
7627                self.advance();
7628                continue;
7629            }
7630            break;
7631        }
7632        let where_ = if matches!(self.peek(), Token::Where) {
7633            self.advance();
7634            Some(self.parse_expr(0)?)
7635        } else {
7636            None
7637        };
7638        Ok(crate::ast::OnConflictAction::Update {
7639            assignments,
7640            where_,
7641        })
7642    }
7643
7644    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7645        let mut items = Vec::new();
7646        loop {
7647            items.push(self.parse_select_item()?);
7648            if matches!(self.peek(), Token::Comma) {
7649                self.advance();
7650            } else {
7651                break;
7652            }
7653        }
7654        Ok(items)
7655    }
7656
7657    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7658        if matches!(self.peek(), Token::Star) {
7659            self.advance();
7660            return Ok(SelectItem::Wildcard);
7661        }
7662        let expr = self.parse_expr(0)?;
7663        let alias = self.parse_optional_alias();
7664        Ok(SelectItem::Expr { expr, alias })
7665    }
7666
7667    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7668        // v7.37.43-T4.5 — `LATERAL jsonb_each_text(<expr>)` —
7669        // set-returning function whose argument may reference a
7670        // preceding FROM item. We rewrite this to
7671        // `LATERAL (SELECT key, value FROM jsonb_each_text(<expr>)
7672        // AS __srf__) AS <alias>` so the existing LATERAL subquery
7673        // executor handles per-outer-row evaluation and the
7674        // SRF-primary jsonb_each_text path handles the inner
7675        // materialisation. Sentori 0067 backfill is the dogfood
7676        // shape.
7677        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7678            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("jsonb_each_text"))
7679            && matches!(self.tokens.get(self.pos + 2), Some(Token::LParen))
7680        {
7681            self.advance(); // LATERAL
7682            self.advance(); // jsonb_each_text
7683            self.advance(); // (
7684            let arg = self.parse_expr(0)?;
7685            if !matches!(self.peek(), Token::RParen) {
7686                return Err(self.err(alloc::format!(
7687                    "expected ')' after LATERAL jsonb_each_text() argument, got {:?}",
7688                    self.peek()
7689                )));
7690            }
7691            self.advance();
7692            let (alias_ident, column_aliases) = self.parse_optional_alias_with_columns();
7693            let alias = alias_ident
7694                .clone()
7695                .unwrap_or_else(|| "jsonb_each_text".to_string());
7696            // Synthesise: SELECT __srf__.key AS <key_alias>, __srf__.value AS <value_alias>
7697            //               FROM jsonb_each_text(<arg>) AS __srf__
7698            // PG's `AS kv(key, value)` column-alias list maps
7699            // positions to names; default to (key, value) when
7700            // omitted (matching the SRF's natural column names).
7701            let srf_alias = "__srf__".to_string();
7702            let key_alias = column_aliases
7703                .first()
7704                .cloned()
7705                .unwrap_or_else(|| "key".to_string());
7706            let value_alias = column_aliases
7707                .get(1)
7708                .cloned()
7709                .unwrap_or_else(|| "value".to_string());
7710            let inner_select = crate::ast::SelectStatement {
7711                ctes: Vec::new(),
7712                distinct: false,
7713                items: alloc::vec![
7714                    crate::ast::SelectItem::Expr {
7715                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7716                            qualifier: Some(srf_alias.clone()),
7717                            name: "key".to_string(),
7718                        }),
7719                        alias: Some(key_alias),
7720                    },
7721                    crate::ast::SelectItem::Expr {
7722                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7723                            qualifier: Some(srf_alias.clone()),
7724                            name: "value".to_string(),
7725                        }),
7726                        alias: Some(value_alias),
7727                    },
7728                ],
7729                from: Some(crate::ast::FromClause {
7730                    primary: TableRef {
7731                        name: srf_alias.clone(),
7732                        alias: Some(srf_alias.clone()),
7733                        as_of_segment: None,
7734                        unnest_expr: None,
7735                        unnest_column_aliases: Vec::new(),
7736                        generate_series_args: None,
7737                        lateral_subquery: None,
7738                        jsonb_each_text_arg: Some(Box::new(arg)),
7739                    },
7740                    joins: Vec::new(),
7741                }),
7742                where_: None,
7743                group_by: None,
7744                group_by_all: false,
7745                having: None,
7746                unions: Vec::new(),
7747                order_by: Vec::new(),
7748                limit: None,
7749                offset: None,
7750                limit_with_ties: false,
7751            };
7752            return Ok(TableRef {
7753                name: alias.clone(),
7754                alias: Some(alias),
7755                as_of_segment: None,
7756                unnest_expr: None,
7757                unnest_column_aliases: Vec::new(),
7758                generate_series_args: None,
7759                lateral_subquery: Some(Box::new(inner_select)),
7760                jsonb_each_text_arg: None,
7761            });
7762        }
7763        // v7.37.43-T4.5 — bare `CROSS JOIN jsonb_each_text(t.col)`
7764        // without an explicit `LATERAL` keyword is the same shape
7765        // PG accepts (SRF naturally licences lateral correlation).
7766        // We mirror the LATERAL rewrite when the argument syntactic-
7767        // ally references an outer column (Column { qualifier:
7768        // Some(_), … }). For simplicity we apply the rewrite
7769        // whenever the SRF directly follows JOIN/CROSS JOIN/comma
7770        // in the FROM-list — caller-side join parsing positions
7771        // this peek correctly.
7772        // (Implementation note: detection lives below; the LATERAL
7773        // branch above already covers the explicit form; the bare
7774        // form falls through to the plain SRF arm and the engine
7775        // treats it as a constant-arg SRF if no outer reference is
7776        // present.)
7777        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7778        // table. Detect at the head so it claims precedence over
7779        // every other table-ref shape (unnest / generate_series /
7780        // bare ident); the lateral subquery itself follows the
7781        // regular SELECT grammar.
7782        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7783            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7784        {
7785            self.advance(); // LATERAL
7786            self.advance(); // (
7787            // Parse the inner SELECT.
7788            let inner = match self.parse_one_statement()? {
7789                Statement::Select(s) => s,
7790                other => {
7791                    return Err(self.err(alloc::format!(
7792                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7793                    )));
7794                }
7795            };
7796            if !matches!(self.peek(), Token::RParen) {
7797                return Err(self.err(alloc::format!(
7798                    "expected ')' after LATERAL subquery, got {:?}",
7799                    self.peek()
7800                )));
7801            }
7802            self.advance();
7803            let alias_ident = self.parse_optional_alias();
7804            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7805            return Ok(TableRef {
7806                name,
7807                alias: alias_ident,
7808                as_of_segment: None,
7809                unnest_expr: None,
7810                unnest_column_aliases: Vec::new(),
7811                generate_series_args: None,
7812                lateral_subquery: Some(Box::new(inner)),
7813                jsonb_each_text_arg: None,
7814            });
7815        }
7816        // v7.37.43-T4.5 — `jsonb_each_text(<expr>)` set-returning
7817        // function as a FROM item. Emits one row per (key, value)
7818        // pair in the JSONB object argument as TEXT columns. May
7819        // be wrapped in CROSS JOIN LATERAL when the argument
7820        // references a preceding FROM item (sentori migration
7821        // 0067 backfill shape: `CROSS JOIN LATERAL
7822        // jsonb_each_text(t.json_col) AS kv(key, value)`).
7823        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("jsonb_each_text"))
7824            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7825        {
7826            self.advance(); // jsonb_each_text
7827            self.advance(); // (
7828            let arg = self.parse_expr(0)?;
7829            if !matches!(self.peek(), Token::RParen) {
7830                return Err(self.err(alloc::format!(
7831                    "expected ')' after jsonb_each_text() argument, got {:?}",
7832                    self.peek()
7833                )));
7834            }
7835            self.advance();
7836            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7837            let name = alias_ident
7838                .clone()
7839                .unwrap_or_else(|| "jsonb_each_text".to_string());
7840            return Ok(TableRef {
7841                name,
7842                alias: alias_ident,
7843                as_of_segment: None,
7844                unnest_expr: None,
7845                unnest_column_aliases: Vec::new(),
7846                generate_series_args: None,
7847                lateral_subquery: None,
7848                jsonb_each_text_arg: Some(Box::new(arg)),
7849            });
7850        }
7851        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
7852        // source. Detect at the head before the bare-ident fallback;
7853        // unnest is not a reserved token.
7854        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
7855            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7856        {
7857            self.advance(); // unnest
7858            self.advance(); // (
7859            let expr = self.parse_expr(0)?;
7860            if !matches!(self.peek(), Token::RParen) {
7861                return Err(self.err(alloc::format!(
7862                    "expected ')' after unnest() argument, got {:?}",
7863                    self.peek()
7864                )));
7865            }
7866            self.advance();
7867            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
7868            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
7869            return Ok(TableRef {
7870                name,
7871                alias: alias_ident,
7872                as_of_segment: None,
7873                unnest_expr: Some(Box::new(expr)),
7874                unnest_column_aliases,
7875                generate_series_args: None,
7876                lateral_subquery: None,
7877                jsonb_each_text_arg: None,
7878            });
7879        }
7880        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7881        // [, step])` set-returning source. Same shape as unnest:
7882        // detect at the head, parse the comma-separated arg list,
7883        // dispatch downstream through the engine's set-returning
7884        // path. Supports integer triplets (mailrs's `WITH row_no AS
7885        // (SELECT * FROM generate_series(1, N))` pattern) and
7886        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
7887        // date-range iteration pattern, which pre-3.10 had no
7888        // direct equivalent in SPG).
7889        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
7890            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7891        {
7892            self.advance(); // generate_series
7893            self.advance(); // (
7894            let mut args: Vec<Expr> = Vec::new();
7895            loop {
7896                args.push(self.parse_expr(0)?);
7897                if matches!(self.peek(), Token::Comma) {
7898                    self.advance();
7899                    continue;
7900                }
7901                break;
7902            }
7903            if !matches!(self.peek(), Token::RParen) {
7904                return Err(self.err(alloc::format!(
7905                    "expected ')' after generate_series() arguments, got {:?}",
7906                    self.peek()
7907                )));
7908            }
7909            self.advance();
7910            if args.len() < 2 || args.len() > 3 {
7911                return Err(self.err(alloc::format!(
7912                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
7913                    args.len()
7914                )));
7915            }
7916            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7917            let name = alias_ident
7918                .clone()
7919                .unwrap_or_else(|| "generate_series".to_string());
7920            return Ok(TableRef {
7921                name,
7922                alias: alias_ident,
7923                as_of_segment: None,
7924                unnest_expr: None,
7925                unnest_column_aliases: Vec::new(),
7926                generate_series_args: Some(args),
7927                lateral_subquery: None,
7928                jsonb_each_text_arg: None,
7929            });
7930        }
7931        // v7.16.2 — preserve information_schema / pg_catalog
7932        // qualifiers (mailrs round-10 A.3). The generic
7933        // `expect_ident_like` strip silently drops the schema;
7934        // we want the engine to recognise these PG meta tables
7935        // and synthesise rows from the live catalog. Produce a
7936        // synthetic name (`__spg_info_columns` etc.) so the
7937        // engine's SELECT-side router can dispatch without
7938        // clashing with any user-defined `columns` table.
7939        let name = if let Some(synth) = self.try_peek_meta_qualified() {
7940            synth
7941        } else if let Some(synth) = self.try_peek_meta_bare() {
7942            synth
7943        } else {
7944            self.expect_ident_like()?
7945        };
7946        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
7947        // time-travel clause. Parse BEFORE the alias so the
7948        // alias can still ride at the tail (`tbl AS OF SEGMENT
7949        // '5' alias`). `AS` is a reserved keyword token, while
7950        // `OF` and `SEGMENT` are bare idents.
7951        let as_of_segment = if matches!(self.peek(), Token::As)
7952            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
7953        {
7954            self.advance(); // AS
7955            self.advance(); // OF
7956            let kw = match self.peek().clone() {
7957                Token::Ident(s) | Token::QuotedIdent(s) => s,
7958                other => {
7959                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
7960                }
7961            };
7962            if !kw.eq_ignore_ascii_case("segment") {
7963                return Err(self.err(format!(
7964                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
7965                )));
7966            }
7967            self.advance();
7968            // Segment id literal — accept either a string or
7969            // integer for operator ergonomics.
7970            let id = match self.advance() {
7971                Token::String(s) => s
7972                    .parse::<u32>()
7973                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7974                Token::Integer(n) => u32::try_from(n)
7975                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7976                other => {
7977                    return Err(self.err(format!(
7978                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
7979                    )));
7980                }
7981            };
7982            Some(id)
7983        } else {
7984            None
7985        };
7986        let alias = self.parse_optional_alias();
7987        Ok(TableRef {
7988            name,
7989            alias,
7990            as_of_segment,
7991            unnest_expr: None,
7992            unnest_column_aliases: Vec::new(),
7993            generate_series_args: None,
7994            lateral_subquery: None,
7995            jsonb_each_text_arg: None,
7996        })
7997    }
7998
7999    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
8000    /// but also accepts `AS alias(col [, col, …])` — the
8001    /// PG-standard table-function column-list form. The column
8002    /// list is only honoured when paired with `UNNEST(...)` in
8003    /// the parent; other call sites currently discard it.
8004    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
8005        let alias = self.parse_optional_alias();
8006        if alias.is_none() {
8007            return (None, Vec::new());
8008        }
8009        let mut cols: Vec<String> = Vec::new();
8010        if matches!(self.peek(), Token::LParen) {
8011            self.advance();
8012            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
8013                self.advance();
8014                cols.push(s);
8015                if matches!(self.peek(), Token::Comma) {
8016                    self.advance();
8017                    continue;
8018                }
8019                break;
8020            }
8021            if matches!(self.peek(), Token::RParen) {
8022                self.advance();
8023            }
8024        }
8025        (alias, cols)
8026    }
8027
8028    /// FROM-clause: a primary table reference plus zero-or-more joined
8029    /// peers expressed via either `, <table>` (cross-product, no ON) or
8030    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
8031    /// the join list flat (left-associative nested-loop semantics).
8032    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
8033        let primary = self.parse_table_ref()?;
8034        let mut joins = Vec::new();
8035        loop {
8036            // `, <table>` — cross-product with no ON.
8037            if matches!(self.peek(), Token::Comma) {
8038                self.advance();
8039                let table = self.parse_table_ref()?;
8040                joins.push(FromJoin {
8041                    kind: JoinKind::Cross,
8042                    table,
8043                    on: None,
8044                });
8045                continue;
8046            }
8047            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
8048            // CROSS JOIN, and bare JOIN (defaults to INNER).
8049            let kind =
8050                match self.peek() {
8051                    Token::Inner => {
8052                        self.advance();
8053                        if !matches!(self.peek(), Token::Join) {
8054                            return Err(self
8055                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
8056                        }
8057                        self.advance();
8058                        JoinKind::Inner
8059                    }
8060                    Token::Left => {
8061                        self.advance();
8062                        if matches!(self.peek(), Token::Outer) {
8063                            self.advance();
8064                        }
8065                        if !matches!(self.peek(), Token::Join) {
8066                            return Err(self.err(format!(
8067                                "expected JOIN after LEFT [OUTER], got {:?}",
8068                                self.peek()
8069                            )));
8070                        }
8071                        self.advance();
8072                        JoinKind::Left
8073                    }
8074                    Token::Cross => {
8075                        self.advance();
8076                        if !matches!(self.peek(), Token::Join) {
8077                            return Err(self
8078                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
8079                        }
8080                        self.advance();
8081                        JoinKind::Cross
8082                    }
8083                    Token::Join => {
8084                        self.advance();
8085                        JoinKind::Inner
8086                    }
8087                    _ => break,
8088                };
8089            let table = self.parse_table_ref()?;
8090            // v7.37.7 C.1 — USING (col_list) sugar. Desugars to
8091            // `prev_table.col1 = table.col1 AND prev_table.col2 = table.col2 …`
8092            // where prev_table is the most-recent left-side table
8093            // (the previous join's table if any, else the FROM primary).
8094            // PG semantics around column merging are richer (USING'd
8095            // cols become deduplicated single output columns); for
8096            // sugar purposes the predicate-only form covers the
8097            // baseline corpus shape and chained `… JOIN x USING (k)
8098            // JOIN y USING (k)` calls.
8099            let using_match = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using"));
8100            let on = if matches!(self.peek(), Token::On) {
8101                self.advance();
8102                Some(self.parse_expr(0)?)
8103            } else if using_match {
8104                self.advance();
8105                if !matches!(self.peek(), Token::LParen) {
8106                    return Err(self.err(format!(
8107                        "expected '(' after USING, got {:?}",
8108                        self.peek()
8109                    )));
8110                }
8111                self.advance();
8112                let mut cols: Vec<String> = Vec::new();
8113                loop {
8114                    match self.peek().clone() {
8115                        Token::Ident(s) | Token::QuotedIdent(s) => {
8116                            self.advance();
8117                            cols.push(s);
8118                        }
8119                        other => {
8120                            return Err(self.err(format!(
8121                                "expected column name inside USING (…), got {other:?}"
8122                            )));
8123                        }
8124                    }
8125                    match self.peek() {
8126                        Token::Comma => {
8127                            self.advance();
8128                            continue;
8129                        }
8130                        Token::RParen => {
8131                            self.advance();
8132                            break;
8133                        }
8134                        other => {
8135                            return Err(self.err(format!(
8136                                "expected ',' or ')' inside USING (…), got {other:?}"
8137                            )));
8138                        }
8139                    }
8140                }
8141                if cols.is_empty() {
8142                    return Err(self.err("USING (…) requires at least one column".to_string()));
8143                }
8144                // Pick the left-side alias: prev join's table if any,
8145                // else FROM primary. Use alias when present, else
8146                // table name (PG-equivalent qualifier).
8147                let left_qual: String = joins
8148                    .last()
8149                    .map(|j| {
8150                        j.table
8151                            .alias
8152                            .clone()
8153                            .unwrap_or_else(|| j.table.name.clone())
8154                    })
8155                    .unwrap_or_else(|| {
8156                        primary
8157                            .alias
8158                            .clone()
8159                            .unwrap_or_else(|| primary.name.clone())
8160                    });
8161                let right_qual = table
8162                    .alias
8163                    .clone()
8164                    .unwrap_or_else(|| table.name.clone());
8165                let mut iter = cols.into_iter().map(|c| Expr::Binary {
8166                    lhs: alloc::boxed::Box::new(Expr::Column(crate::ast::ColumnName {
8167                        qualifier: Some(left_qual.clone()),
8168                        name: c.clone(),
8169                    })),
8170                    op: crate::ast::BinOp::Eq,
8171                    rhs: alloc::boxed::Box::new(Expr::Column(crate::ast::ColumnName {
8172                        qualifier: Some(right_qual.clone()),
8173                        name: c,
8174                    })),
8175                });
8176                let first = iter.next().expect("at least one col");
8177                Some(iter.fold(first, |acc, pred| Expr::Binary {
8178                    lhs: alloc::boxed::Box::new(acc),
8179                    op: crate::ast::BinOp::And,
8180                    rhs: alloc::boxed::Box::new(pred),
8181                }))
8182            } else if kind == JoinKind::Cross {
8183                None
8184            } else {
8185                return Err(self.err(format!(
8186                    "expected ON or USING after {:?} JOIN, got {:?}",
8187                    kind,
8188                    self.peek()
8189                )));
8190            };
8191            joins.push(FromJoin { kind, table, on });
8192        }
8193        Ok(FromClause { primary, joins })
8194    }
8195
8196    /// Optional alias after an expression or table:
8197    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
8198    /// accepted (PG-style implicit alias). Returns `None` if the next token
8199    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
8200    fn parse_optional_alias(&mut self) -> Option<String> {
8201        if matches!(self.peek(), Token::As) {
8202            self.advance();
8203            // After AS, the next token MUST be an identifier-like — if not,
8204            // we still return None and let the caller surface the error on the
8205            // next expectation. v0.2 keeps the alias path forgiving; the
8206            // corpus tests don't exercise the malformed case.
8207            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
8208                return self.expect_ident_like().ok();
8209            }
8210            return None;
8211        }
8212        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
8213        // grammar reserves a long list of follow-keywords from the
8214        // alias slot. SPG's bareword approximation: skip a small
8215        // set of idents that would otherwise be swallowed as the
8216        // table alias and break trailing clauses like CREATE
8217        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
8218        // CONFLICT WHERE shapes.
8219        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8220            if is_alias_stopword(s) {
8221                return None;
8222            }
8223            return self.expect_ident_like().ok();
8224        }
8225        None
8226    }
8227
8228    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
8229    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8230        // v7.30.2 (mailrs round-25 ask 2) — nesting budget: a parse
8231        // error beats a stack overflow (an overflow aborts the
8232        // embedding host process).
8233        self.enter_nested()?;
8234        let r = self.parse_expr_inner(min_prec);
8235        self.nest_depth -= 1;
8236        r
8237    }
8238
8239    fn parse_expr_inner(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8240        let mut lhs = self.parse_unary()?;
8241        let mut chain_len = 0usize;
8242        while let Some((op, prec)) = binop_from(self.peek()) {
8243            if prec < min_prec {
8244                break;
8245            }
8246            // v7.30.2 (mailrs round-25 ask 2) — the chain builds
8247            // iteratively but evaluates and drops recursively;
8248            // depth beyond the budget overflows worker stacks.
8249            chain_len += 1;
8250            if chain_len > MAX_BINARY_CHAIN {
8251                return Err(self.err(alloc::format!(
8252                    "more than {MAX_BINARY_CHAIN} chained binary operators; rewrite long OR-equality chains as IN (…)"
8253                )));
8254            }
8255            self.advance();
8256            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
8257            // ANY is a bare ident; ALL is a reserved Token. Both
8258            // require an immediate `(` to disambiguate from
8259            // identifier columns named `any` / `all`.
8260            let any_kind = match self.peek() {
8261                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
8262                    Some(false)
8263                }
8264                Token::Ident(s) | Token::QuotedIdent(s)
8265                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
8266                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
8267                {
8268                    Some(s.eq_ignore_ascii_case("any"))
8269                }
8270                _ => None,
8271            };
8272            if let Some(is_any) = any_kind {
8273                self.advance(); // ident
8274                self.advance(); // (
8275                let arr = self.parse_expr(0)?;
8276                if !matches!(self.peek(), Token::RParen) {
8277                    return Err(self.err(alloc::format!(
8278                        "expected ')' after ANY/ALL argument, got {:?}",
8279                        self.peek()
8280                    )));
8281                }
8282                self.advance();
8283                lhs = Expr::AnyAll {
8284                    expr: Box::new(lhs),
8285                    op,
8286                    array: Box::new(arr),
8287                    is_any,
8288                };
8289                continue;
8290            }
8291            let rhs = self.parse_expr(prec + 1)?;
8292            lhs = Expr::Binary {
8293                lhs: Box::new(lhs),
8294                op,
8295                rhs: Box::new(rhs),
8296            };
8297        }
8298        Ok(lhs)
8299    }
8300
8301    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
8302        match self.peek() {
8303            Token::Not => {
8304                self.advance();
8305                // NOT sits between AND (2) and comparisons (4) — bind everything
8306                // ≥3, which leaves AND/OR outside.
8307                let e = self.parse_expr(3)?;
8308                Ok(Expr::Unary {
8309                    op: UnOp::Not,
8310                    expr: Box::new(e),
8311                })
8312            }
8313            Token::Minus => {
8314                self.advance();
8315                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
8316                // `<->` slotted into 5 and arithmetic shifted up).
8317                let e = self.parse_expr(8)?;
8318                Ok(Expr::Unary {
8319                    op: UnOp::Neg,
8320                    expr: Box::new(e),
8321                })
8322            }
8323            Token::Tilde => {
8324                self.advance();
8325                // Bitwise NOT binds like unary minus.
8326                let e = self.parse_expr(8)?;
8327                Ok(Expr::Unary {
8328                    op: UnOp::BitNot,
8329                    expr: Box::new(e),
8330                })
8331            }
8332            _ => self.parse_atom(),
8333        }
8334    }
8335
8336    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
8337        let tok_pos = self.pos;
8338        match self.advance() {
8339            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
8340            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
8341            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
8342            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
8343            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
8344            Token::Null => Ok(Expr::Literal(Literal::Null)),
8345            // v6.1.1 — `$N` placeholder. The actual Value lookup
8346            // happens in the engine eval path against the prepared-
8347            // statement bind buffer.
8348            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
8349            Token::LParen => {
8350                // v4.10: `(SELECT ...)` in expression position is a
8351                // scalar subquery; otherwise it's a parenthesised
8352                // expression. Peek for SELECT keyword to dispatch.
8353                if matches!(self.peek(), Token::Select) {
8354                    let inner = self.parse_select_stmt()?;
8355                    match self.advance() {
8356                        Token::RParen => {
8357                            let Statement::Select(s) = inner else {
8358                                unreachable!("parse_select_stmt returns Select")
8359                            };
8360                            Ok(Expr::ScalarSubquery(Box::new(s)))
8361                        }
8362                        other => Err(ParseError {
8363                            message: format!("expected ')' after scalar subquery, got {other:?}"),
8364                            token_pos: self.pos.saturating_sub(1),
8365                        }),
8366                    }
8367                } else {
8368                    let e = self.parse_expr(0)?;
8369                    match self.advance() {
8370                        Token::RParen => Ok(e),
8371                        other => Err(ParseError {
8372                            message: format!("expected ')', got {other:?}"),
8373                            token_pos: self.pos.saturating_sub(1),
8374                        }),
8375                    }
8376                }
8377            }
8378            Token::LBracket => self.parse_vector_literal_body(),
8379            Token::Extract => self.parse_extract_atom(),
8380            Token::Interval => self.parse_interval_atom(),
8381            // `LEFT` is a reserved-keyword token because the
8382            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
8383            // When `left` is followed by `(` we're in expression
8384            // position calling the PG `left(string, n)` function;
8385            // rebuild the AST as a regular function call so the
8386            // engine's apply_function dispatch picks it up.
8387            Token::Left if matches!(self.peek(), Token::LParen) => {
8388                self.advance(); // (
8389                let mut args = Vec::new();
8390                if !matches!(self.peek(), Token::RParen) {
8391                    loop {
8392                        args.push(self.parse_expr(0)?);
8393                        match self.peek() {
8394                            Token::Comma => {
8395                                self.advance();
8396                            }
8397                            Token::RParen => break,
8398                            other => {
8399                                return Err(self.err(alloc::format!(
8400                                    "expected ',' or ')' in left() args, got {other:?}"
8401                                )));
8402                            }
8403                        }
8404                    }
8405                }
8406                self.advance(); // )
8407                Ok(Expr::FunctionCall {
8408                    name: "left".into(),
8409                    args,
8410                })
8411            }
8412            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
8413            // token; we match on the bare ident. NOT is a token
8414            // (consumed in the comparison rung), but `EXISTS (...)`
8415            // at the top of an expression starts here.
8416            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
8417                self.parse_exists_atom(false)
8418            }
8419            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
8420            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
8421            // CASE is a bare ident; we dispatch on lowercase match.
8422            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
8423                self.parse_case_atom()
8424            }
8425            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
8426            // is not a reserved token; we match by case-insensitive
8427            // ident. The opening `[` must follow immediately.
8428            Token::Ident(s) | Token::QuotedIdent(s)
8429                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
8430            {
8431                self.advance(); // consume `[`
8432                let mut items: Vec<Expr> = Vec::new();
8433                if !matches!(self.peek(), Token::RBracket) {
8434                    loop {
8435                        items.push(self.parse_expr(0)?);
8436                        match self.peek() {
8437                            Token::Comma => {
8438                                self.advance();
8439                            }
8440                            Token::RBracket => break,
8441                            other => {
8442                                return Err(self.err(alloc::format!(
8443                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
8444                                )));
8445                            }
8446                        }
8447                    }
8448                }
8449                self.advance(); // consume `]`
8450                Ok(Expr::Array(items))
8451            }
8452            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
8453            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
8454            // We special-case before the generic ident dispatch so
8455            // the AGAINST clause never reaches the function-call
8456            // loop (which would mis-read `(cols) AGAINST` as a
8457            // call with no trailing modifier). The shape is
8458            // rewritten to a Boolean OR over per-column
8459            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8460            // term)` so the existing FTS evaluator handles
8461            // semantics — the fulltext-GIN built at CREATE TABLE
8462            // time is currently a "real index that survives dump
8463            // round-trip"; the planner hook that actually uses
8464            // it for posting-list intersection lands in a later
8465            // sub-phase (Phase 2.2b) without touching this surface.
8466            Token::Ident(s) | Token::QuotedIdent(s)
8467                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
8468            {
8469                self.parse_match_against_atom()
8470            }
8471            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
8472            // v7.37.43-T4 — PG-unreserved keywords are legal column /
8473            // alias names in expression context too. `release` appears
8474            // in sentori `0003_partition_events.sql` as both a column
8475            // reference (SELECT … release …) and an INSERT column list
8476            // entry. Mirrors `expect_ident_like`'s expansion of the
8477            // identifier set.
8478            other if unreserved_keyword_text(&other).is_some() => {
8479                let s = unreserved_keyword_text(&other).unwrap();
8480                self.finish_ident_atom(s)
8481            }
8482            other => Err(ParseError {
8483                message: format!("unexpected token {other:?} in expression"),
8484                token_pos: tok_pos,
8485            }),
8486        }
8487        // After parsing the atom, fold any postfix `::vector` casts.
8488        .and_then(|atom| self.finish_postfix_casts(atom))
8489    }
8490
8491    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
8492    /// Both bind tighter than any binary op.
8493    /// Shared cast-target parser for postfix `::TYPE` and the
8494    /// standard `CAST(expr AS TYPE)` form (v7.25, round-17).
8495    fn parse_cast_target(&mut self) -> Result<CastTarget, ParseError> {
8496        let target = match self.advance() {
8497            Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
8498                "int" | "integer" | "int4" => {
8499                    if matches!(self.peek(), Token::LBracket)
8500                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8501                    {
8502                        self.advance();
8503                        self.advance();
8504                        CastTarget::IntArray
8505                    } else {
8506                        CastTarget::Int
8507                    }
8508                }
8509                "bigint" | "int8" => {
8510                    if matches!(self.peek(), Token::LBracket)
8511                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8512                    {
8513                        self.advance();
8514                        self.advance();
8515                        CastTarget::BigIntArray
8516                    } else {
8517                        CastTarget::BigInt
8518                    }
8519                }
8520                "float" | "double" | "real" => CastTarget::Float,
8521                "text" => {
8522                    // v7.10.11 — `::TEXT[]` widens to TextArray.
8523                    if matches!(self.peek(), Token::LBracket)
8524                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8525                    {
8526                        self.advance();
8527                        self.advance();
8528                        CastTarget::TextArray
8529                    } else {
8530                        CastTarget::Text
8531                    }
8532                }
8533                "bool" | "boolean" => CastTarget::Bool,
8534                "vector" => CastTarget::Vector,
8535                "date" => CastTarget::Date,
8536                "timestamp" | "datetime" => CastTarget::Timestamp,
8537                "timestamptz" => CastTarget::Timestamptz,
8538                "interval" => CastTarget::Interval,
8539                "json" => CastTarget::Json,
8540                "jsonb" => CastTarget::Jsonb,
8541                "regtype" => CastTarget::RegType,
8542                "regclass" => CastTarget::RegClass,
8543                // v7.12.0 — `::tsvector` / `::tsquery`.
8544                // Engine decodes the LHS text via the PG
8545                // external form parser.
8546                "tsvector" => CastTarget::TsVector,
8547                "tsquery" => CastTarget::TsQuery,
8548                // v7.17.0 — `::uuid`. Engine decodes the LHS
8549                // text via `spg_storage::parse_uuid_str`.
8550                "uuid" => CastTarget::Uuid,
8551                // v7.18 — `::bytea`. Engine decodes the LHS
8552                // text via the PG hex form (`'\xdeadbeef'`)
8553                // or escape form (`'\\x05\\x00'`). Closes
8554                // mailrs D-pre #3 reverse-acceptance gap.
8555                "bytea" => CastTarget::Bytea,
8556                // v7.37.5 ship triage — generic typed-cast escape.
8557                // Anything the long-tail PG type ident table knows
8558                // about(network/bit/geometry/multirange/etc.)flows
8559                // through `CastTarget::Named(canonical)`; the engine
8560                // resolves via `column_type_to_data_type` and dispatches
8561                // through the typed `coerce_value` path. Truly
8562                // unrecognised idents still hit the error arm below
8563                // because the engine rejects them.
8564                other => {
8565                    // Optional `(N[, M])` precision args — `::numeric(10,2)`,
8566                    // `::varchar(255)`, etc. Capture into the canonical
8567                    // `name(p,s)` form so `type_name_to_data_type` can
8568                    // reconstruct the `DataType::Numeric { precision,
8569                    // scale }` (and similar param-carrying types).
8570                    let mut name = other.to_string();
8571                    if matches!(self.peek(), Token::LParen) {
8572                        let mut buf = alloc::string::String::from("(");
8573                        let mut depth = 0usize;
8574                        loop {
8575                            match self.advance() {
8576                                Token::LParen => {
8577                                    depth += 1;
8578                                    if depth > 1 {
8579                                        buf.push('(');
8580                                    }
8581                                }
8582                                Token::RParen => {
8583                                    depth -= 1;
8584                                    if depth == 0 {
8585                                        buf.push(')');
8586                                        break;
8587                                    }
8588                                    buf.push(')');
8589                                }
8590                                Token::Comma => buf.push(','),
8591                                Token::Integer(n) => buf.push_str(&alloc::format!("{n}")),
8592                                Token::Eof => break,
8593                                _ => {}
8594                            }
8595                        }
8596                        name.push_str(&buf);
8597                    }
8598                    // Optional postfix `[]` widens to the array form —
8599                    // `::BOOL[]`, `::NUMERIC[]`, `::SMALLINT[]`, etc.
8600                    // The engine's `type_name_to_data_type` recognises
8601                    // the canonical `<ty>_array` form.
8602                    if matches!(self.peek(), Token::LBracket)
8603                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8604                    {
8605                        self.advance();
8606                        self.advance();
8607                        name.push_str("_array");
8608                    }
8609                    CastTarget::Named(name)
8610                }
8611            },
8612            Token::Interval => CastTarget::Interval,
8613            other => {
8614                return Err(ParseError {
8615                    message: format!("expected type ident after `::`, got {other:?}"),
8616                    token_pos: self.pos.saturating_sub(1),
8617                });
8618            }
8619        };
8620        // v7.37.5 ship triage — postfix `[]` widens a scalar cast
8621        // target to its array sibling. Closed-enum arms (Bool /
8622        // SmallInt / Numeric / Float / Date / …) didn't carry the
8623        // explicit widening that Text / Int / BigInt did, so
8624        // `::BOOL[]` / `::NUMERIC[]` etc. surfaced as a parse
8625        // error. The widening here mirrors the per-arm Text /
8626        // Int / BigInt logic above + folds the new ζ-A first-class
8627        // types through `CastTarget::Named("<ty>_array")`.
8628        if matches!(self.peek(), Token::LBracket)
8629            && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8630        {
8631            let widened = match &target {
8632                CastTarget::Bool => Some(CastTarget::Named("bool_array".to_string())),
8633                CastTarget::Date => Some(CastTarget::Named("date_array".to_string())),
8634                CastTarget::Timestamp | CastTarget::Timestamptz => {
8635                    Some(CastTarget::Named("timestamptz_array".to_string()))
8636                }
8637                CastTarget::Uuid => Some(CastTarget::Named("uuid_array".to_string())),
8638                CastTarget::Json | CastTarget::Jsonb => {
8639                    Some(CastTarget::Named("jsonb_array".to_string()))
8640                }
8641                CastTarget::Bytea => Some(CastTarget::Named("bytea_array".to_string())),
8642                CastTarget::Interval => Some(CastTarget::Named("interval_array".to_string())),
8643                CastTarget::Float => Some(CastTarget::Named("float_array".to_string())),
8644                CastTarget::Named(name) => {
8645                    let mut a = name.clone();
8646                    a.push_str("_array");
8647                    Some(CastTarget::Named(a))
8648                }
8649                // Int / BigInt / Text / Vector / TsVector / TsQuery /
8650                // RegType / RegClass / TextArray / IntArray /
8651                // BigIntArray already finalised — leave as is.
8652                _ => None,
8653            };
8654            if let Some(w) = widened {
8655                self.advance();
8656                self.advance();
8657                return Ok(w);
8658            }
8659        }
8660        Ok(target)
8661    }
8662
8663    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
8664        loop {
8665            if matches!(self.peek(), Token::DoubleColon) {
8666                self.advance();
8667                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
8668                // target set to include INTERVAL (reserved Token),
8669                // TIMESTAMPTZ, and PG catalog regtype / regclass.
8670                // mailrs follow-up H3a + H3b.
8671                let target = self.parse_cast_target()?;
8672                expr = Expr::Cast {
8673                    expr: Box::new(expr),
8674                    target,
8675                };
8676                continue;
8677            }
8678            if matches!(self.peek(), Token::Is) {
8679                self.advance();
8680                let negated = if matches!(self.peek(), Token::Not) {
8681                    self.advance();
8682                    true
8683                } else {
8684                    false
8685                };
8686                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
8687                // mailrs pg_dump.
8688                if matches!(self.peek(), Token::Distinct) {
8689                    self.advance();
8690                    if !matches!(self.peek(), Token::From) {
8691                        return Err(self.err(format!(
8692                            "expected FROM after IS{} DISTINCT, got {:?}",
8693                            if negated { " NOT" } else { "" },
8694                            self.peek()
8695                        )));
8696                    }
8697                    self.advance();
8698                    // Right-hand side: parse at the same precedence
8699                    // tier as comparison so `x IS DISTINCT FROM a + b`
8700                    // groups as `x IS DISTINCT FROM (a + b)`.
8701                    let rhs = self.parse_expr(20)?;
8702                    let op = if negated {
8703                        BinOp::IsNotDistinctFrom
8704                    } else {
8705                        BinOp::IsDistinctFrom
8706                    };
8707                    expr = Expr::Binary {
8708                        op,
8709                        lhs: Box::new(expr),
8710                        rhs: Box::new(rhs),
8711                    };
8712                    continue;
8713                }
8714                if !matches!(self.peek(), Token::Null) {
8715                    return Err(self.err(format!(
8716                        "expected NULL or DISTINCT after IS{}, got {:?}",
8717                        if negated { " NOT" } else { "" },
8718                        self.peek()
8719                    )));
8720                }
8721                self.advance();
8722                expr = Expr::IsNull {
8723                    expr: Box::new(expr),
8724                    negated,
8725                };
8726                continue;
8727            }
8728            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
8729            // Look one token ahead so a stray `NOT` not followed by any of
8730            // these flows through to the early return below untouched.
8731            let negated = if matches!(self.peek(), Token::Not) {
8732                let next = self.tokens.get(self.pos + 1);
8733                matches!(next, Some(Token::Between | Token::In | Token::Like))
8734                    || matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("ilike"))
8735            } else {
8736                false
8737            };
8738            if negated {
8739                self.advance();
8740            }
8741            if matches!(self.peek(), Token::Between) {
8742                expr = self.parse_between_tail(expr, negated)?;
8743                continue;
8744            }
8745            if matches!(self.peek(), Token::In) {
8746                expr = self.parse_in_tail(expr, negated)?;
8747                continue;
8748            }
8749            if matches!(self.peek(), Token::Like) {
8750                self.advance();
8751                // Pattern at the same precedence as other comparison RHSes —
8752                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
8753                let pattern = self.parse_expr(5)?;
8754                expr = Expr::Like {
8755                    expr: Box::new(expr),
8756                    pattern: Box::new(pattern),
8757                    negated,
8758                    case_insensitive: false,
8759                };
8760                continue;
8761            }
8762            // v7.25 (round-17) — ILIKE: case-insensitive LIKE. The
8763            // keyword reaches us as a plain identifier.
8764            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("ilike")) {
8765                self.advance();
8766                let pattern = self.parse_expr(5)?;
8767                expr = Expr::Like {
8768                    expr: Box::new(expr),
8769                    pattern: Box::new(pattern),
8770                    negated,
8771                    case_insensitive: true,
8772                };
8773                continue;
8774            }
8775            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
8776            // returns NULL for out-of-range. Multiple subscripts
8777            // chain: `a[i][j]` parses left-to-right.
8778            if matches!(self.peek(), Token::LBracket) {
8779                self.advance();
8780                let index = self.parse_expr(0)?;
8781                if !matches!(self.peek(), Token::RBracket) {
8782                    return Err(self.err(alloc::format!(
8783                        "expected ']' after array index, got {:?}",
8784                        self.peek()
8785                    )));
8786                }
8787                self.advance();
8788                expr = Expr::ArraySubscript {
8789                    target: Box::new(expr),
8790                    index: Box::new(index),
8791                };
8792                continue;
8793            }
8794            return Ok(expr);
8795        }
8796    }
8797
8798    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
8799    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
8800    /// `AND` is not swallowed.
8801    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8802        self.advance(); // BETWEEN
8803        let low = self.parse_expr(5)?;
8804        if !matches!(self.peek(), Token::And) {
8805            return Err(self.err(format!(
8806                "expected AND after BETWEEN low bound, got {:?}",
8807                self.peek()
8808            )));
8809        }
8810        self.advance();
8811        let high = self.parse_expr(5)?;
8812        let target = Box::new(expr);
8813        let combined = Expr::Binary {
8814            lhs: Box::new(Expr::Binary {
8815                lhs: target.clone(),
8816                op: BinOp::GtEq,
8817                rhs: Box::new(low),
8818            }),
8819            op: BinOp::And,
8820            rhs: Box::new(Expr::Binary {
8821                lhs: target,
8822                op: BinOp::LtEq,
8823                rhs: Box::new(high),
8824            }),
8825        };
8826        Ok(maybe_not(combined, negated))
8827    }
8828
8829    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
8830    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
8831    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
8832    /// Caller already consumed the leading `WITH` ident.
8833    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
8834        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
8835        // Comes through as an identifier; consume it if present and
8836        // mark every CTE in the clause as recursive (PG semantics —
8837        // the flag is per-WITH, not per-CTE).
8838        let mut recursive = false;
8839        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8840            && s.eq_ignore_ascii_case("recursive")
8841        {
8842            self.advance();
8843            recursive = true;
8844        }
8845        let mut ctes = Vec::new();
8846        loop {
8847            let name = self.expect_ident_like()?;
8848            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
8849            // PG uses these to rename the body's output columns; we
8850            // do the same below by overriding `columns[i].name`.
8851            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
8852                self.advance();
8853                let mut names = Vec::new();
8854                loop {
8855                    names.push(self.expect_ident_like()?);
8856                    if matches!(self.peek(), Token::Comma) {
8857                        self.advance();
8858                        continue;
8859                    }
8860                    break;
8861                }
8862                if !matches!(self.peek(), Token::RParen) {
8863                    return Err(self.err(format!(
8864                        "expected ')' to close CTE column list, got {:?}",
8865                        self.peek()
8866                    )));
8867                }
8868                self.advance();
8869                names
8870            } else {
8871                Vec::new()
8872            };
8873            // AS is a reserved Token::As (used by SELECT-item / FROM
8874            // aliasing) — handle it specially rather than as a bare
8875            // ident.
8876            if !matches!(self.peek(), Token::As) {
8877                return Err(self.err(format!(
8878                    "expected AS after CTE name {name:?}, got {:?}",
8879                    self.peek()
8880                )));
8881            }
8882            self.advance();
8883            if !matches!(self.peek(), Token::LParen) {
8884                return Err(self.err(format!(
8885                    "expected '(' after AS in WITH clause, got {:?}",
8886                    self.peek()
8887                )));
8888            }
8889            self.advance();
8890            // v7.37.43-T4.4 — accept INSERT / UPDATE / DELETE (with
8891            // RETURNING) as the CTE body in addition to SELECT.
8892            // PG writable CTE semantics. UPDATE / DELETE come in as
8893            // bare Idents (lexer keeps SELECT / INSERT as reserved
8894            // tokens but treats the rest of DML as case-insensitive
8895            // idents).
8896            let is_update_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
8897            let is_delete_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
8898            let body = match self.peek() {
8899                Token::Select => {
8900                    let inner = self.parse_select_stmt()?;
8901                    let Statement::Select(s) = inner else {
8902                        unreachable!("parse_select_stmt returns Select");
8903                    };
8904                    crate::ast::CteBody::Select(s)
8905                }
8906                Token::Insert => {
8907                    let inner = self.parse_one_statement()?;
8908                    let Statement::Insert(s) = inner else {
8909                        unreachable!("Token::Insert routes to Insert");
8910                    };
8911                    crate::ast::CteBody::Insert(alloc::boxed::Box::new(s))
8912                }
8913                _ if is_update_kw => {
8914                    let inner = self.parse_one_statement()?;
8915                    let Statement::Update(s) = inner else {
8916                        return Err(
8917                            self.err(format!("expected UPDATE inside WITH (…), got {inner:?}"))
8918                        );
8919                    };
8920                    crate::ast::CteBody::Update(alloc::boxed::Box::new(s))
8921                }
8922                _ if is_delete_kw => {
8923                    let inner = self.parse_one_statement()?;
8924                    let Statement::Delete(s) = inner else {
8925                        return Err(
8926                            self.err(format!("expected DELETE inside WITH (…), got {inner:?}"))
8927                        );
8928                    };
8929                    crate::ast::CteBody::Delete(alloc::boxed::Box::new(s))
8930                }
8931                other => {
8932                    return Err(self.err(format!(
8933                        "WITH body must be SELECT / INSERT / UPDATE / DELETE, got {other:?}"
8934                    )));
8935                }
8936            };
8937            if !matches!(self.peek(), Token::RParen) {
8938                return Err(self.err(format!(
8939                    "expected ')' after CTE body, got {:?}",
8940                    self.peek()
8941                )));
8942            }
8943            self.advance();
8944            ctes.push(crate::ast::Cte {
8945                name,
8946                body,
8947                recursive,
8948                column_overrides,
8949            });
8950            if matches!(self.peek(), Token::Comma) {
8951                self.advance();
8952                continue;
8953            }
8954            break;
8955        }
8956        // v7.37.43-T4.4 — the outer body may be SELECT (classical),
8957        // or INSERT / UPDATE / DELETE (writable CTE outer). Attach
8958        // the parsed CTEs to whichever statement the body produces.
8959        let outer_is_update = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
8960        let outer_is_delete = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
8961        match self.peek() {
8962            Token::Select => {
8963                let body_stmt = self.parse_select_stmt()?;
8964                let Statement::Select(mut body) = body_stmt else {
8965                    unreachable!()
8966                };
8967                body.ctes = ctes;
8968                Ok(Statement::Select(body))
8969            }
8970            Token::Insert => {
8971                let body_stmt = self.parse_one_statement()?;
8972                let Statement::Insert(mut body) = body_stmt else {
8973                    unreachable!()
8974                };
8975                body.ctes = ctes;
8976                Ok(Statement::Insert(body))
8977            }
8978            _ if outer_is_update => {
8979                let body_stmt = self.parse_one_statement()?;
8980                let Statement::Update(mut body) = body_stmt else {
8981                    return Err(self.err(format!("expected UPDATE after WITH clause")));
8982                };
8983                body.ctes = ctes;
8984                Ok(Statement::Update(body))
8985            }
8986            _ if outer_is_delete => {
8987                let body_stmt = self.parse_one_statement()?;
8988                let Statement::Delete(mut body) = body_stmt else {
8989                    return Err(self.err(format!("expected DELETE after WITH clause")));
8990                };
8991                body.ctes = ctes;
8992                Ok(Statement::Delete(body))
8993            }
8994            other => Err(self.err(format!(
8995                "expected SELECT / INSERT / UPDATE / DELETE after WITH clause, got {other:?}"
8996            ))),
8997        }
8998    }
8999
9000    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
9001    /// already consumed the leading `EXISTS` ident via
9002    /// `self.advance()`.
9003    /// v7.13.0 — parse the rest of a `CASE … END` expression after
9004    /// the leading `CASE` ident has been consumed (mailrs round-5
9005    /// G9). Supports both the searched form
9006    /// (`CASE WHEN cond THEN val …`) and the simple form
9007    /// (`CASE operand WHEN val THEN val …`).
9008    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
9009        // Disambiguate searched vs simple form: if the next token
9010        // is `WHEN`, we're in the searched form. Otherwise the
9011        // intervening expression is the operand.
9012        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
9013            None
9014        } else {
9015            Some(Box::new(self.parse_expr(0)?))
9016        };
9017        let mut branches: Vec<(Expr, Expr)> = Vec::new();
9018        loop {
9019            match self.peek() {
9020                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
9021                    self.advance();
9022                    let cond = self.parse_expr(0)?;
9023                    match self.peek() {
9024                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
9025                            self.advance();
9026                        }
9027                        other => {
9028                            return Err(self.err(alloc::format!(
9029                                "expected THEN after CASE WHEN <expr>, got {other:?}"
9030                            )));
9031                        }
9032                    }
9033                    let value = self.parse_expr(0)?;
9034                    branches.push((cond, value));
9035                }
9036                _ => break,
9037            }
9038        }
9039        if branches.is_empty() {
9040            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
9041        }
9042        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
9043        {
9044            self.advance();
9045            Some(Box::new(self.parse_expr(0)?))
9046        } else {
9047            None
9048        };
9049        match self.peek() {
9050            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
9051                self.advance();
9052            }
9053            other => {
9054                return Err(self.err(alloc::format!(
9055                    "expected END to close CASE expression, got {other:?}"
9056                )));
9057            }
9058        }
9059        Ok(Expr::Case {
9060            operand,
9061            branches,
9062            else_branch,
9063        })
9064    }
9065
9066    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
9067        if !matches!(self.peek(), Token::LParen) {
9068            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
9069        }
9070        self.advance();
9071        let inner = self.parse_select_stmt()?;
9072        if !matches!(self.peek(), Token::RParen) {
9073            return Err(self.err(format!(
9074                "expected ')' after EXISTS-subquery, got {:?}",
9075                self.peek()
9076            )));
9077        }
9078        self.advance();
9079        let Statement::Select(s) = inner else {
9080            unreachable!("parse_select_stmt returns Select")
9081        };
9082        Ok(Expr::Exists {
9083            subquery: Box::new(s),
9084            negated,
9085        })
9086    }
9087
9088    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
9089        self.advance(); // IN
9090        if !matches!(self.peek(), Token::LParen) {
9091            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
9092        }
9093        self.advance();
9094        // v4.10: `IN (SELECT ...)` — subquery branch.
9095        if matches!(self.peek(), Token::Select) {
9096            let inner = self.parse_select_stmt()?;
9097            if !matches!(self.peek(), Token::RParen) {
9098                return Err(self.err(format!(
9099                    "expected ')' after IN-subquery, got {:?}",
9100                    self.peek()
9101                )));
9102            }
9103            self.advance();
9104            let Statement::Select(s) = inner else {
9105                unreachable!("parse_select_stmt always returns Statement::Select")
9106            };
9107            return Ok(Expr::InSubquery {
9108                expr: Box::new(expr),
9109                subquery: Box::new(s),
9110                negated,
9111            });
9112        }
9113        let mut elements = Vec::new();
9114        if !matches!(self.peek(), Token::RParen) {
9115            loop {
9116                elements.push(self.parse_expr(0)?);
9117                match self.peek() {
9118                    Token::Comma => {
9119                        self.advance();
9120                    }
9121                    Token::RParen => break,
9122                    other => {
9123                        return Err(
9124                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
9125                        );
9126                    }
9127                }
9128            }
9129        }
9130        self.advance(); // ')'
9131        // v7.30.2 (mailrs round-25) — flat InList node instead of a
9132        // left-deep OR-Eq chain: chain depth scaled with the element
9133        // count and overflowed the stack (eval + drop are recursive).
9134        if elements.is_empty() {
9135            return Ok(maybe_not(Expr::Literal(Literal::Bool(false)), negated));
9136        }
9137        Ok(Expr::InList {
9138            expr: Box::new(expr),
9139            list: elements,
9140            negated,
9141        })
9142    }
9143
9144    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
9145    /// already consumed by the caller. Elements must be numeric literals
9146    /// (with optional unary `-`); any compound expression is rejected at
9147    /// parse time so the runtime never needs to evaluate inside a vector.
9148    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
9149    /// has already consumed the `EXTRACT` token before calling us —
9150    /// we pick up at the opening `(`.
9151    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
9152    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
9153    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
9154    /// per-column OR-fold of
9155    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
9156    /// term)` so the existing FTS evaluator handles semantics.
9157    ///
9158    /// The mode modifier is accepted-and-ignored at v7.17 — all
9159    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
9160    /// mode operators (`+foo -bar`) would need their own parser
9161    /// (Phase 2.2c); customers who hit them today already get a
9162    /// correct lexeme-match against the bare term, only without
9163    /// the +/- precedence the customer asked for.
9164    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
9165        // Already at `MATCH`-consumed position; the dispatcher
9166        // confirmed the next token is `(`.
9167        if !matches!(self.peek(), Token::LParen) {
9168            return Err(self.err(alloc::format!(
9169                "expected '(' after MATCH, got {:?}",
9170                self.peek()
9171            )));
9172        }
9173        self.advance();
9174        let mut cols: Vec<Expr> = Vec::new();
9175        loop {
9176            cols.push(self.parse_expr(0)?);
9177            match self.peek() {
9178                Token::Comma => {
9179                    self.advance();
9180                }
9181                Token::RParen => break,
9182                other => {
9183                    return Err(self.err(alloc::format!(
9184                        "expected ',' or ')' in MATCH column list, got {other:?}"
9185                    )));
9186                }
9187            }
9188        }
9189        self.advance(); // ')'
9190        // Expect AGAINST.
9191        match self.peek() {
9192            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
9193                self.advance();
9194            }
9195            other => {
9196                return Err(self.err(alloc::format!(
9197                    "expected AGAINST after MATCH column list, got {other:?}"
9198                )));
9199            }
9200        }
9201        if !matches!(self.peek(), Token::LParen) {
9202            return Err(self.err(alloc::format!(
9203                "expected '(' after AGAINST, got {:?}",
9204                self.peek()
9205            )));
9206        }
9207        self.advance();
9208        // Read AGAINST's argument as a single primary token —
9209        // string literal, placeholder, or column-ref ident. We
9210        // can't call `parse_expr` / `parse_unary` here because
9211        // the postfix chain inside `parse_atom` would greedily
9212        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
9213        // and fail at "expected '(' after IN". Customers always
9214        // write a literal or bound parameter in AGAINST, so this
9215        // restriction is non-blocking; the error path explains
9216        // the limit if a more complex expression shows up.
9217        let term = match self.advance() {
9218            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
9219            Token::Placeholder(n) => Expr::Placeholder(n),
9220            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
9221                qualifier: None,
9222                name: s,
9223            }),
9224            other => {
9225                return Err(self.err(alloc::format!(
9226                    "MATCH ... AGAINST(<term>) expects a string literal, \
9227                     bound parameter, or column ref, got {other:?}"
9228                )));
9229            }
9230        };
9231        // Optional mode tail — accept-and-ignore at v7.17:
9232        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
9233        //   IN BOOLEAN MODE
9234        //   WITH QUERY EXPANSION
9235        loop {
9236            match self.peek() {
9237                // IN lexes as a reserved Token::In, not an ident,
9238                // so it gets its own arm.
9239                Token::In => {
9240                    self.advance();
9241                }
9242                Token::Ident(s) | Token::QuotedIdent(s)
9243                    if s.eq_ignore_ascii_case("natural")
9244                        || s.eq_ignore_ascii_case("language")
9245                        || s.eq_ignore_ascii_case("boolean")
9246                        || s.eq_ignore_ascii_case("mode")
9247                        || s.eq_ignore_ascii_case("with")
9248                        || s.eq_ignore_ascii_case("query")
9249                        || s.eq_ignore_ascii_case("expansion") =>
9250                {
9251                    self.advance();
9252                }
9253                _ => break,
9254            }
9255        }
9256        if !matches!(self.peek(), Token::RParen) {
9257            return Err(self.err(alloc::format!(
9258                "expected ')' to close AGAINST, got {:?}",
9259                self.peek()
9260            )));
9261        }
9262        self.advance();
9263        // Build per-column `to_tsvector('simple', col) @@
9264        // plainto_tsquery('simple', term)` and OR-fold.
9265        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
9266        let plainto = Expr::FunctionCall {
9267            name: String::from("plainto_tsquery"),
9268            args: alloc::vec![simple_lit(), term.clone()],
9269        };
9270        let mut folded: Option<Expr> = None;
9271        for col in cols {
9272            let to_tsv = Expr::FunctionCall {
9273                name: String::from("to_tsvector"),
9274                args: alloc::vec![simple_lit(), col],
9275            };
9276            let leaf = Expr::Binary {
9277                lhs: Box::new(to_tsv),
9278                op: crate::ast::BinOp::TsMatch,
9279                rhs: Box::new(plainto.clone()),
9280            };
9281            folded = Some(match folded {
9282                None => leaf,
9283                Some(prev) => Expr::Binary {
9284                    lhs: Box::new(prev),
9285                    op: crate::ast::BinOp::Or,
9286                    rhs: Box::new(leaf),
9287                },
9288            });
9289        }
9290        match folded {
9291            Some(e) => Ok(e),
9292            None => Err(self.err(String::from(
9293                "MATCH(...) AGAINST(...) requires at least one column",
9294            ))),
9295        }
9296    }
9297
9298    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
9299        if !matches!(self.peek(), Token::LParen) {
9300            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
9301        }
9302        self.advance();
9303        let field_name = self.expect_ident_like()?;
9304        let field = match field_name.to_ascii_lowercase().as_str() {
9305            "year" => ExtractField::Year,
9306            "month" => ExtractField::Month,
9307            "day" => ExtractField::Day,
9308            "hour" => ExtractField::Hour,
9309            "minute" => ExtractField::Minute,
9310            "second" => ExtractField::Second,
9311            "microsecond" | "microseconds" => ExtractField::Microsecond,
9312            "epoch" => ExtractField::Epoch,
9313            other => {
9314                return Err(self.err(format!(
9315                    "unknown EXTRACT field {other:?}; \
9316                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
9317                )));
9318            }
9319        };
9320        if !matches!(self.peek(), Token::From) {
9321            return Err(self.err(format!(
9322                "expected FROM after EXTRACT field, got {:?}",
9323                self.peek()
9324            )));
9325        }
9326        self.advance();
9327        let source = self.parse_expr(0)?;
9328        if !matches!(self.peek(), Token::RParen) {
9329            return Err(self.err(format!(
9330                "expected ')' to close EXTRACT, got {:?}",
9331                self.peek()
9332            )));
9333        }
9334        self.advance();
9335        Ok(Expr::Extract {
9336            field,
9337            source: Box::new(source),
9338        })
9339    }
9340
9341    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
9342    /// is already consumed; we expect a single string literal next and
9343    /// resolve it into `Literal::Interval` at parse time so the engine
9344    /// never has to re-tokenise inside the string.
9345    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
9346        let tok = self.advance();
9347        let Token::String(text) = tok else {
9348            return Err(self.err(format!(
9349                "expected string literal after INTERVAL, got {tok:?}"
9350            )));
9351        };
9352        let (months, days, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
9353            message: format!(
9354                "cannot parse INTERVAL {text:?}; \
9355                     expected `<n> <unit> [<n> <unit> ...]` with units \
9356                     microsecond[s], millisecond[s], second[s], minute[s], \
9357                     hour[s], day[s], week[s], month[s], year[s]"
9358            ),
9359            token_pos: self.pos.saturating_sub(1),
9360        })?;
9361        Ok(Expr::Literal(Literal::Interval {
9362            months,
9363            days,
9364            micros,
9365            text,
9366        }))
9367    }
9368
9369    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
9370        let mut elems = Vec::new();
9371        if matches!(self.peek(), Token::RBracket) {
9372            self.advance();
9373            return Ok(Expr::Literal(Literal::Vector(elems)));
9374        }
9375        loop {
9376            let e = self.parse_expr(0)?;
9377            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
9378                message: format!("vector element must be a numeric literal, got {e:?}"),
9379                token_pos: self.pos,
9380            })?;
9381            elems.push(x);
9382            match self.peek() {
9383                Token::Comma => {
9384                    self.advance();
9385                }
9386                Token::RBracket => {
9387                    self.advance();
9388                    break;
9389                }
9390                other => {
9391                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
9392                }
9393            }
9394        }
9395        Ok(Expr::Literal(Literal::Vector(elems)))
9396    }
9397
9398    /// Atom that started with an identifier: could be `t.col`, `col`, or
9399    /// `func(arg, ...)`. Detect each shape by looking at the next token.
9400    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
9401    /// [, ...])`. Caller has already consumed `OVER`. Either clause
9402    /// is optional; an empty `()` is also legal (PG semantics).
9403    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
9404    /// modifier between `name(args)` and `OVER (...)`. Default is
9405    /// `Respect`. Unrecognised idents leave the stream unchanged.
9406    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
9407        let Token::Ident(s) = self.peek().clone() else {
9408            return NullTreatment::Respect;
9409        };
9410        let is_ignore = s.eq_ignore_ascii_case("ignore");
9411        let is_respect = s.eq_ignore_ascii_case("respect");
9412        if !is_ignore && !is_respect {
9413            return NullTreatment::Respect;
9414        }
9415        // Lookahead for NULLS — only consume both tokens together.
9416        // pos+1 must hold a "nulls" ident.
9417        if self.pos + 1 < self.tokens.len()
9418            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
9419            && s2.eq_ignore_ascii_case("nulls")
9420        {
9421            self.advance();
9422            self.advance();
9423            return if is_ignore {
9424                NullTreatment::Ignore
9425            } else {
9426                NullTreatment::Respect
9427            };
9428        }
9429        NullTreatment::Respect
9430    }
9431
9432    /// v7.32 (mailrs round-29) — `agg(args) FILTER (WHERE cond)`.
9433    /// `FILTER` is an unreserved keyword, so it arrives as an `Ident`
9434    /// (same shape as the `OVER` tail). Consumes the whole clause and
9435    /// returns the predicate; returns `None` when no `FILTER` follows.
9436    fn parse_filter_clause(&mut self) -> Result<Option<Box<Expr>>, ParseError> {
9437        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9438            return Ok(None);
9439        };
9440        if !s.eq_ignore_ascii_case("filter") {
9441            return Ok(None);
9442        }
9443        self.advance(); // FILTER
9444        if !matches!(self.peek(), Token::LParen) {
9445            return Err(self.err(format!("expected '(' after FILTER, got {:?}", self.peek())));
9446        }
9447        self.advance(); // (
9448        if !matches!(self.peek(), Token::Where) {
9449            return Err(self.err(format!(
9450                "expected WHERE inside FILTER (...), got {:?}",
9451                self.peek()
9452            )));
9453        }
9454        self.advance(); // WHERE
9455        let cond = self.parse_expr(0)?;
9456        if !matches!(self.peek(), Token::RParen) {
9457            return Err(self.err(format!(
9458                "expected ')' to close FILTER (WHERE ...), got {:?}",
9459                self.peek()
9460            )));
9461        }
9462        self.advance(); // )
9463        Ok(Some(Box::new(cond)))
9464    }
9465
9466    /// v7.32 (round-29) — `WITHIN GROUP ( ORDER BY <sort_spec> )` tail
9467    /// for ordered-set aggregates. `WITHIN` is unreserved (arrives as an
9468    /// `Ident`); `GROUP` and `ORDER`/`BY` are keywords. Returns the sort
9469    /// keys, or an empty vec when no `WITHIN GROUP` follows.
9470    fn parse_within_group_clause(&mut self) -> Result<Vec<OrderBy>, ParseError> {
9471        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9472            return Ok(Vec::new());
9473        };
9474        if !s.eq_ignore_ascii_case("within") {
9475            return Ok(Vec::new());
9476        }
9477        self.advance(); // WITHIN
9478        if !matches!(self.peek(), Token::Group) {
9479            return Err(self.err(format!(
9480                "expected GROUP after WITHIN, got {:?}",
9481                self.peek()
9482            )));
9483        }
9484        self.advance(); // GROUP
9485        if !matches!(self.peek(), Token::LParen) {
9486            return Err(self.err(format!(
9487                "expected '(' after WITHIN GROUP, got {:?}",
9488                self.peek()
9489            )));
9490        }
9491        self.advance(); // (
9492        if !matches!(self.peek(), Token::Order) {
9493            return Err(self.err(format!(
9494                "expected ORDER BY inside WITHIN GROUP (...), got {:?}",
9495                self.peek()
9496            )));
9497        }
9498        self.advance(); // ORDER
9499        if !matches!(self.peek(), Token::By) {
9500            return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9501        }
9502        self.advance(); // BY
9503        let mut keys: Vec<OrderBy> = Vec::new();
9504        loop {
9505            let expr = self.parse_expr(0)?;
9506            let desc = if matches!(self.peek(), Token::Desc) {
9507                self.advance();
9508                true
9509            } else if matches!(self.peek(), Token::Asc) {
9510                self.advance();
9511                false
9512            } else {
9513                false
9514            };
9515            let nulls_first = self.parse_optional_nulls_placement()?;
9516            keys.push(OrderBy {
9517                expr,
9518                desc,
9519                nulls_first,
9520            });
9521            if matches!(self.peek(), Token::Comma) {
9522                self.advance();
9523            } else {
9524                break;
9525            }
9526        }
9527        if !matches!(self.peek(), Token::RParen) {
9528            return Err(self.err(format!(
9529                "expected ')' to close WITHIN GROUP (ORDER BY ...), got {:?}",
9530                self.peek()
9531            )));
9532        }
9533        self.advance(); // )
9534        Ok(keys)
9535    }
9536
9537    /// No frame clause is supported.
9538    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
9539    fn parse_over_clause(
9540        &mut self,
9541    ) -> Result<
9542        (
9543            Vec<Expr>,
9544            Vec<(Expr, bool, Option<bool>)>,
9545            Option<WindowFrame>,
9546        ),
9547        ParseError,
9548    > {
9549        if !matches!(self.peek(), Token::LParen) {
9550            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
9551        }
9552        self.advance();
9553        let mut partition_by = Vec::new();
9554        let mut order_by = Vec::new();
9555        // PARTITION BY ?
9556        // v7.37.6-B promoted PARTITION to a reserved keyword
9557        // (Token::Partition); pre-7.37.6-B catalogs lexed it as
9558        // `Token::Ident("partition")`. Accept both so older sources
9559        // and the new lexer surface land on the same path.
9560        let is_partition_kw = match self.peek() {
9561            Token::Partition => true,
9562            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("partition"),
9563            _ => false,
9564        };
9565        if is_partition_kw {
9566            self.advance();
9567            if !matches!(self.peek(), Token::By) {
9568                return Err(self.err(format!(
9569                    "expected BY after PARTITION, got {:?}",
9570                    self.peek()
9571                )));
9572            }
9573            self.advance();
9574            loop {
9575                partition_by.push(self.parse_expr(0)?);
9576                if matches!(self.peek(), Token::Comma) {
9577                    self.advance();
9578                    continue;
9579                }
9580                break;
9581            }
9582        }
9583        // ORDER BY ?
9584        if matches!(self.peek(), Token::Order) {
9585            self.advance();
9586            if !matches!(self.peek(), Token::By) {
9587                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9588            }
9589            self.advance();
9590            loop {
9591                let e = self.parse_expr(0)?;
9592                let desc = if matches!(self.peek(), Token::Desc) {
9593                    self.advance();
9594                    true
9595                } else if matches!(self.peek(), Token::Asc) {
9596                    self.advance();
9597                    false
9598                } else {
9599                    false
9600                };
9601                // v7.24.1 — NULLS FIRST/LAST inside OVER (…).
9602                let nulls_first = self.parse_optional_nulls_placement()?;
9603                order_by.push((e, desc, nulls_first));
9604                if matches!(self.peek(), Token::Comma) {
9605                    self.advance();
9606                    continue;
9607                }
9608                break;
9609            }
9610        }
9611        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
9612        // Both keywords come through the lexer as identifiers; match
9613        // case-insensitively.
9614        let mut frame: Option<WindowFrame> = None;
9615        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
9616            let kind = if s.eq_ignore_ascii_case("rows") {
9617                Some(FrameKind::Rows)
9618            } else if s.eq_ignore_ascii_case("range") {
9619                Some(FrameKind::Range)
9620            } else {
9621                None
9622            };
9623            if let Some(kind) = kind {
9624                self.advance();
9625                frame = Some(self.parse_frame_tail(kind)?);
9626            }
9627        }
9628        if !matches!(self.peek(), Token::RParen) {
9629            return Err(self.err(format!(
9630                "expected ')' to close OVER clause, got {:?}",
9631                self.peek()
9632            )));
9633        }
9634        self.advance();
9635        Ok((partition_by, order_by, frame))
9636    }
9637
9638    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
9639    /// or `RANGE` keyword was just consumed. Accepts both
9640    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
9641    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
9642    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
9643    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
9644        if matches!(self.peek(), Token::Between) {
9645            self.advance();
9646            let start = self.parse_frame_bound()?;
9647            if !matches!(self.peek(), Token::And) {
9648                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
9649            }
9650            self.advance();
9651            let end = self.parse_frame_bound()?;
9652            Ok(WindowFrame {
9653                kind,
9654                start,
9655                end: Some(end),
9656            })
9657        } else {
9658            let start = self.parse_frame_bound()?;
9659            Ok(WindowFrame {
9660                kind,
9661                start,
9662                end: None,
9663            })
9664        }
9665    }
9666
9667    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
9668    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
9669    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
9670        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
9671        if let Token::Integer(n) = *self.peek() {
9672            self.advance();
9673            let n: u64 = u64::try_from(n).map_err(|_| {
9674                self.err(format!(
9675                    "invalid frame offset {n} — expected non-negative integer"
9676                ))
9677            })?;
9678            let dir = self.expect_ident_like()?;
9679            return if dir.eq_ignore_ascii_case("preceding") {
9680                Ok(FrameBound::OffsetPreceding(n))
9681            } else if dir.eq_ignore_ascii_case("following") {
9682                Ok(FrameBound::OffsetFollowing(n))
9683            } else {
9684                Err(self.err(format!(
9685                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
9686                )))
9687            };
9688        }
9689        let first = self.expect_ident_like()?;
9690        if first.eq_ignore_ascii_case("unbounded") {
9691            let dir = self.expect_ident_like()?;
9692            return if dir.eq_ignore_ascii_case("preceding") {
9693                Ok(FrameBound::UnboundedPreceding)
9694            } else if dir.eq_ignore_ascii_case("following") {
9695                Ok(FrameBound::UnboundedFollowing)
9696            } else {
9697                Err(self.err(format!(
9698                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
9699                )))
9700            };
9701        }
9702        if first.eq_ignore_ascii_case("current") {
9703            let row = self.expect_ident_like()?;
9704            if !row.eq_ignore_ascii_case("row") {
9705                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
9706            }
9707            return Ok(FrameBound::CurrentRow);
9708        }
9709        Err(self.err(format!(
9710            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
9711        )))
9712    }
9713
9714    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
9715        if matches!(self.peek(), Token::Dot) {
9716            self.advance();
9717            let name = self.expect_ident_like()?;
9718            // v7.14.0 — schema-qualified function call
9719            // `<schema>.<fn>(args)`. PG dumps emit
9720            // `pg_catalog.set_config(...)` in the preamble. SPG
9721            // is single-namespace: drop the schema prefix and
9722            // route the dispatch on the bare function name.
9723            if matches!(self.peek(), Token::LParen) {
9724                return self.finish_ident_atom(name);
9725            }
9726            return Ok(Expr::Column(ColumnName {
9727                qualifier: Some(first),
9728                name,
9729            }));
9730        }
9731        if matches!(self.peek(), Token::LParen) {
9732            self.advance();
9733            // `COUNT(*)` — special-cased here because `*` isn't a normal
9734            // expression token. Lower-case match on `first` since the lexer
9735            // folds identifiers.
9736            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
9737                self.advance();
9738                if !matches!(self.peek(), Token::RParen) {
9739                    return Err(self.err(format!(
9740                        "expected ')' after COUNT(*), got {:?}",
9741                        self.peek()
9742                    )));
9743                }
9744                self.advance();
9745                // v7.32 (round-29) — `COUNT(*) FILTER (WHERE …)`.
9746                let filter = self.parse_filter_clause()?;
9747                // v4.12: COUNT(*) OVER (...) — same window tail.
9748                let null_treatment = self.parse_null_treatment_modifier();
9749                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9750                    && s.eq_ignore_ascii_case("over")
9751                {
9752                    if filter.is_some() {
9753                        return Err(
9754                            self.err("FILTER on window functions is not supported yet".into())
9755                        );
9756                    }
9757                    self.advance();
9758                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
9759                    return Ok(Expr::WindowFunction {
9760                        name: "count_star".into(),
9761                        args: Vec::new(),
9762                        partition_by,
9763                        order_by,
9764                        frame,
9765                        null_treatment,
9766                    });
9767                }
9768                if let Some(filter) = filter {
9769                    return Ok(Expr::AggregateOrdered {
9770                        call: Box::new(Expr::FunctionCall {
9771                            name: "count_star".into(),
9772                            args: Vec::new(),
9773                        }),
9774                        order_by: Vec::new(),
9775                        distinct: false,
9776                        filter: Some(filter),
9777                    });
9778                }
9779                return Ok(Expr::FunctionCall {
9780                    name: "count_star".into(),
9781                    args: Vec::new(),
9782                });
9783            }
9784            // Function call. PG-style: zero-or-more comma-separated args.
9785            let mut args = Vec::new();
9786            let mut agg_order_by: Vec<OrderBy> = Vec::new();
9787            // v7.25 (round-17) — `COUNT(DISTINCT x)` and friends.
9788            // v7.32 (round-29) — accept the dual `ALL` quantifier too
9789            // (the default; ORMs emit `COUNT(ALL x)` / `SUM(ALL x)`).
9790            let agg_distinct = if matches!(self.peek(), Token::Distinct) {
9791                self.advance();
9792                true
9793            } else if matches!(self.peek(), Token::All) {
9794                self.advance();
9795                false
9796            } else {
9797                false
9798            };
9799            if !matches!(self.peek(), Token::RParen) {
9800                loop {
9801                    args.push(self.parse_expr(0)?);
9802                    // v7.25 (round-17) — standard `CAST(expr AS type)`.
9803                    // The `::` cast already worked; this lowers the
9804                    // function form onto the same Expr::Cast node.
9805                    if first.eq_ignore_ascii_case("cast")
9806                        && args.len() == 1
9807                        && matches!(self.peek(), Token::As)
9808                    {
9809                        self.advance();
9810                        let target = self.parse_cast_target()?;
9811                        if !matches!(self.peek(), Token::RParen) {
9812                            return Err(self.err(format!(
9813                                "expected ')' to close CAST, got {:?}",
9814                                self.peek()
9815                            )));
9816                        }
9817                        self.advance();
9818                        return Ok(Expr::Cast {
9819                            expr: Box::new(args.pop().expect("one arg")),
9820                            target,
9821                        });
9822                    }
9823                    // v7.37.7 C.1.8 — PG `substring(str FROM pos FOR len)` syntactic
9824                    // form. Desugars to the comma-list shape evaluator already
9825                    // handles. Triggered after the first arg when the function
9826                    // name is substring / substr and the next token is FROM
9827                    // (a reserved keyword in PG; SPG also reserves it).
9828                    if (first.eq_ignore_ascii_case("substring")
9829                        || first.eq_ignore_ascii_case("substr"))
9830                        && args.len() == 1
9831                        && matches!(self.peek(), Token::From)
9832                    {
9833                        self.advance();
9834                        let start = self.parse_expr(0)?;
9835                        args.push(start);
9836                        if matches!(self.peek(), Token::For) {
9837                            self.advance();
9838                            let length = self.parse_expr(0)?;
9839                            args.push(length);
9840                        }
9841                        if !matches!(self.peek(), Token::RParen) {
9842                            return Err(self.err(format!(
9843                                "expected ')' to close substring(... FROM ... [FOR ...]), got {:?}",
9844                                self.peek()
9845                            )));
9846                        }
9847                        self.advance();
9848                        return Ok(Expr::FunctionCall {
9849                            name: first.to_ascii_lowercase(),
9850                            args,
9851                        });
9852                    }
9853                    // v7.24 (round-16 A) — aggregate-internal
9854                    // ordering: `array_agg(x ORDER BY y DESC NULLS
9855                    // LAST)`. Keys close the argument list.
9856                    if matches!(self.peek(), Token::Order) {
9857                        self.advance();
9858                        if !matches!(self.peek(), Token::By) {
9859                            return Err(self.err(format!(
9860                                "expected BY after ORDER in aggregate args, got {:?}",
9861                                self.peek()
9862                            )));
9863                        }
9864                        self.advance();
9865                        loop {
9866                            let expr = self.parse_expr(0)?;
9867                            let desc = if matches!(self.peek(), Token::Desc) {
9868                                self.advance();
9869                                true
9870                            } else if matches!(self.peek(), Token::Asc) {
9871                                self.advance();
9872                                false
9873                            } else {
9874                                false
9875                            };
9876                            let nulls_first = self.parse_optional_nulls_placement()?;
9877                            agg_order_by.push(OrderBy {
9878                                expr,
9879                                desc,
9880                                nulls_first,
9881                            });
9882                            if matches!(self.peek(), Token::Comma) {
9883                                self.advance();
9884                            } else {
9885                                break;
9886                            }
9887                        }
9888                        if !matches!(self.peek(), Token::RParen) {
9889                            return Err(self.err(format!(
9890                                "expected ')' after aggregate ORDER BY, got {:?}",
9891                                self.peek()
9892                            )));
9893                        }
9894                        break;
9895                    }
9896                    match self.peek() {
9897                        Token::Comma => {
9898                            self.advance();
9899                        }
9900                        Token::RParen => break,
9901                        other => {
9902                            return Err(self.err(format!(
9903                                "expected ',' or ')' in function args, got {other:?}"
9904                            )));
9905                        }
9906                    }
9907                }
9908            }
9909            self.advance(); // consume ')'
9910            // v7.32 (round-29) — ordered-set aggregate tail
9911            // `name(direct_args) WITHIN GROUP (ORDER BY …)`
9912            // (percentile_cont / percentile_disc / mode). The sort spec
9913            // lands in the same `order_by` slot a decorated aggregate
9914            // uses; the executor dispatches on the function name. WITHIN
9915            // GROUP and an intra-argument ORDER BY are mutually
9916            // exclusive (PG rejects both).
9917            let within_group_order = self.parse_within_group_clause()?;
9918            if !within_group_order.is_empty() && !agg_order_by.is_empty() {
9919                return Err(self.err(
9920                    "an aggregate may not carry both an in-argument ORDER BY and WITHIN GROUP"
9921                        .into(),
9922                ));
9923            }
9924            let agg_order_by = if within_group_order.is_empty() {
9925                agg_order_by
9926            } else {
9927                within_group_order
9928            };
9929            // v7.32 (round-29) — `name(args) FILTER (WHERE …)`.
9930            let filter = self.parse_filter_clause()?;
9931            // v4.12: window-function tail — `name(args) OVER (...)`.
9932            // Promotes the just-parsed FunctionCall into a
9933            // WindowFunction node carrying partition + order.
9934            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
9935            // / `RESPECT NULLS OVER (...)` between the closing paren
9936            // and `OVER`.
9937            let null_treatment = self.parse_null_treatment_modifier();
9938            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9939                && s.eq_ignore_ascii_case("over")
9940            {
9941                if filter.is_some() {
9942                    return Err(self.err("FILTER on window functions is not supported yet".into()));
9943                }
9944                self.advance();
9945                let (partition_by, order_by, frame) = self.parse_over_clause()?;
9946                return Ok(Expr::WindowFunction {
9947                    name: first,
9948                    args,
9949                    partition_by,
9950                    order_by,
9951                    frame,
9952                    null_treatment,
9953                });
9954            }
9955            if !agg_order_by.is_empty() || agg_distinct || filter.is_some() {
9956                return Ok(Expr::AggregateOrdered {
9957                    call: Box::new(Expr::FunctionCall { name: first, args }),
9958                    order_by: agg_order_by,
9959                    distinct: agg_distinct,
9960                    filter,
9961                });
9962            }
9963            return Ok(Expr::FunctionCall { name: first, args });
9964        }
9965        // v7.9.20 — SQL-standard parenless keyword expressions
9966        // (PG treats these as functions called without parens).
9967        // Resolve to a synthetic FunctionCall so the engine's
9968        // eval path reuses the existing function-call routing.
9969        // mailrs G3.
9970        let lc = first.to_ascii_lowercase();
9971        if matches!(
9972            lc.as_str(),
9973            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
9974        ) {
9975            return Ok(Expr::FunctionCall {
9976                name: lc,
9977                args: Vec::new(),
9978            });
9979        }
9980        Ok(Expr::Column(ColumnName {
9981            qualifier: None,
9982            name: first,
9983        }))
9984    }
9985}
9986
9987/// v6.8.2 — walk an expression tree and return the first column
9988/// reference's bare name. Used by `parse_create_index_stmt_after_create`
9989/// to derive `CreateIndexStatement.column` from an expression
9990/// key (so downstream planner code resolving a primary column
9991/// position keeps working with expression indexes). Returns
9992/// `None` when the expression has no column ref at all — caller
9993/// surfaces that as a parse error.
9994fn extract_first_column(expr: &Expr) -> Option<String> {
9995    match expr {
9996        Expr::Column(cn) => Some(cn.name.clone()),
9997        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
9998        Expr::Binary { lhs, rhs, .. } => {
9999            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
10000        }
10001        Expr::Unary { expr: e, .. } => extract_first_column(e),
10002        _ => None,
10003    }
10004}
10005
10006fn maybe_not(expr: Expr, negated: bool) -> Expr {
10007    if negated {
10008        Expr::Unary {
10009            op: UnOp::Not,
10010            expr: Box::new(expr),
10011        }
10012    } else {
10013        expr
10014    }
10015}
10016
10017fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
10018    let pair = match tok {
10019        Token::Or => (BinOp::Or, 1),
10020        Token::And => (BinOp::And, 2),
10021        Token::Eq => (BinOp::Eq, 4),
10022        Token::NotEq => (BinOp::NotEq, 4),
10023        Token::Lt => (BinOp::Lt, 4),
10024        Token::LtEq => (BinOp::LtEq, 4),
10025        Token::Gt => (BinOp::Gt, 4),
10026        Token::GtEq => (BinOp::GtEq, 4),
10027        // pgvector distance ops all sit on the same rung — tighter than
10028        // comparisons (4) so `col <-> v < threshold` parses correctly.
10029        Token::L2Distance => (BinOp::L2Distance, 5),
10030        Token::InnerProduct => (BinOp::InnerProduct, 5),
10031        Token::CosineDistance => (BinOp::CosineDistance, 5),
10032        Token::Plus => (BinOp::Add, 6),
10033        Token::Minus => (BinOp::Sub, 6),
10034        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
10035        // by the same level as binary additive arithmetic).
10036        Token::Concat => (BinOp::Concat, 6),
10037        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
10038        // all "other" operators between additive and comparison, so
10039        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
10040        //
10041        // Known divergence (the same one `||` has carried since v1):
10042        // SPG's rung 6 TIES with `+ -`, while PG binds generic
10043        // operators LOOSER than additive — `a & b + 1` is
10044        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
10045        // mixed bitwise/arithmetic. Keeping every generic operator
10046        // on one shared rung is deliberate: splitting bitwise off
10047        // would fix that case but skew `a || b & c`, which PG
10048        // left-folds at a single level.
10049        Token::Pipe => (BinOp::BitOr, 6),
10050        Token::Amp => (BinOp::BitAnd, 6),
10051        Token::Star => (BinOp::Mul, 7),
10052        Token::Slash => (BinOp::Div, 7),
10053        Token::Percent => (BinOp::Mod, 7),
10054        // v4.14: JSON path ops bind tighter than comparisons (4)
10055        // and additive (6) so `doc->'k' = 'v'` parses correctly.
10056        // Same rung as the multiplicative ops.
10057        Token::JsonGet => (BinOp::JsonGet, 7),
10058        Token::JsonGetText => (BinOp::JsonGetText, 7),
10059        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
10060        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
10061        Token::JsonContains => (BinOp::JsonContains, 7),
10062        Token::JsonContainedBy => (BinOp::JsonContainedBy, 7),
10063        Token::JsonKeyExists => (BinOp::JsonKeyExists, 7),
10064        Token::JsonKeysAny => (BinOp::JsonKeysAny, 7),
10065        Token::JsonKeysAll => (BinOp::JsonKeysAll, 7),
10066        // v7.12.2 — `@@` binds at the comparison rung (looser than
10067        // arithmetic, tighter than AND / OR). PG places `@@` at
10068        // the same precedence as `=` / `<`, so we follow.
10069        Token::TsMatch => (BinOp::TsMatch, 4),
10070        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
10071        // PG places these at the comparison rung (same level as `=`),
10072        // so we follow.
10073        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
10074        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
10075        Token::InetContains => (BinOp::InetContains, 4),
10076        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
10077        Token::InetOverlap => (BinOp::InetOverlap, 4),
10078        _ => return None,
10079    };
10080    Some(pair)
10081}
10082
10083#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
10084// `as f32` here is intentional: vector elements widen / narrow into f32 on
10085// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
10086// past ~15 decimal digits — both are acceptable for a fixed-precision
10087// pgvector column.
10088/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
10089/// implicit table alias and break trailing clauses. WITH lands
10090/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
10091/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
10092/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
10093/// / VALUES / FOR / LATERAL — all of which would otherwise be
10094/// silently swallowed by `parse_optional_alias`.
10095fn is_alias_stopword(s: &str) -> bool {
10096    matches!(
10097        s.to_ascii_lowercase().as_str(),
10098        "with"
10099            | "on"
10100            | "where"
10101            | "having"
10102            | "group"
10103            | "order"
10104            | "limit"
10105            | "offset"
10106            | "union"
10107            | "except"
10108            | "intersect"
10109            | "returning"
10110            | "set"
10111            | "values"
10112            | "for"
10113            | "lateral"
10114            | "left"
10115            | "right"
10116            | "inner"
10117            | "outer"
10118            | "full"
10119            | "cross"
10120            | "join"
10121            | "natural"
10122            | "using"
10123            | "fetch"
10124    )
10125}
10126
10127fn extract_numeric_literal(e: &Expr) -> Option<f32> {
10128    match e {
10129        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
10130        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
10131        Expr::Unary {
10132            op: UnOp::Neg,
10133            expr,
10134        } => extract_numeric_literal(expr).map(|x| -x),
10135        _ => None,
10136    }
10137}
10138
10139/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
10140/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
10141/// negative. Returns `None` if any pair fails to parse or no pair is found.
10142///
10143/// Recognised units (case-insensitive, optional trailing `s`):
10144/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
10145/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
10146/// v7.37.5 β — returns `(months, days, micros)`. `days` is preserved
10147/// as its own dimension so `INTERVAL '1 day'` ≠ `INTERVAL '24 hours'`
10148/// (PG-canonical: DST and month-boundary semantics depend on this).
10149/// `week` rolls into `days` (× 7). Sub-day units flow into `micros`.
10150pub fn parse_interval_text(s: &str) -> Option<(i32, i32, i64)> {
10151    let parts: Vec<&str> = s.split_whitespace().collect();
10152    if parts.is_empty() || !parts.len().is_multiple_of(2) {
10153        return None;
10154    }
10155    let mut months: i32 = 0;
10156    let mut days: i32 = 0;
10157    let mut micros: i64 = 0;
10158    let mut i = 0;
10159    while i < parts.len() {
10160        let n: i64 = parts[i].parse().ok()?;
10161        let unit = parts[i + 1].to_ascii_lowercase();
10162        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
10163        match unit_stripped {
10164            "microsecond" => micros = micros.checked_add(n)?,
10165            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
10166            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
10167            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
10168            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
10169            "day" => {
10170                let n32 = i32::try_from(n).ok()?;
10171                days = days.checked_add(n32)?;
10172            }
10173            "week" => {
10174                let n32 = i32::try_from(n).ok()?;
10175                days = days.checked_add(n32.checked_mul(7)?)?;
10176            }
10177            // v7.37.5 ship triage — accept PG's `format_interval`
10178            // canonical output (`0 mons 0 days 0 microseconds`) so
10179            // a round-trip Display → re-parse stays lossless.
10180            "month" | "mon" => {
10181                let n32 = i32::try_from(n).ok()?;
10182                months = months.checked_add(n32)?;
10183            }
10184            "year" => {
10185                let n32 = i32::try_from(n).ok()?;
10186                months = months.checked_add(n32.checked_mul(12)?)?;
10187            }
10188            _ => return None,
10189        }
10190        i += 2;
10191    }
10192    Some((months, days, micros))
10193}
10194
10195/// v7.12.4 — map a bare type-name identifier (the form that
10196/// appears in a function arg list or RETURNS clause) to a
10197/// [`ColumnTypeName`]. Returns `None` for unknown / extension
10198/// types so the caller can preserve them as
10199/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
10200///
10201/// Subset of the full column-type grammar — we deliberately
10202/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
10203/// here because function-arg types in v7.12.4 are mostly the
10204/// bare form (`text`, `int`, `bytea`, …).
10205fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
10206    Some(match ident.to_ascii_lowercase().as_str() {
10207        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
10208        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
10209        "bigint" => ColumnTypeName::BigInt,
10210        "float" | "double" | "real" => ColumnTypeName::Float,
10211        "text" => ColumnTypeName::Text,
10212        "bool" | "boolean" => ColumnTypeName::Bool,
10213        "date" => ColumnTypeName::Date,
10214        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
10215        "timestamptz" => ColumnTypeName::Timestamptz,
10216        "json" => ColumnTypeName::Json,
10217        "jsonb" => ColumnTypeName::Jsonb,
10218        "bytea" | "bytes" => ColumnTypeName::Bytes,
10219        "tsvector" => ColumnTypeName::TsVector,
10220        "tsquery" => ColumnTypeName::TsQuery,
10221        "uuid" => ColumnTypeName::Uuid,
10222        "interval" => ColumnTypeName::Interval,
10223        "time" => ColumnTypeName::Time,
10224        "year" => ColumnTypeName::Year,
10225        "timetz" => ColumnTypeName::TimeTz,
10226        "money" => ColumnTypeName::Money,
10227        _ => return None,
10228    })
10229}
10230
10231/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
10232/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
10233///
10234/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
10235/// / embedded SQL land in v7.12.5+):
10236///
10237/// ```text
10238///   body          := [ws] block [ws]
10239///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
10240///   stmt          := assign | return
10241///   assign        := assign_target := expr
10242///   assign_target := ( NEW | OLD ) . ident | ident
10243///   return        := RETURN ( NEW | OLD | NULL | expr )
10244/// ```
10245///
10246/// `expr` is parsed by recursing into the regular `Parser` — so a
10247/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
10248/// NEW.subject || ' ' || NEW.sender)` body shape works without
10249/// the body parser knowing what `to_tsvector` is.
10250///
10251/// Errors here cause the caller to fall back to
10252/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
10253/// successful, but the executor will refuse to invoke the
10254/// function with an "unparseable body" error.
10255/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
10256/// from the crate root as `spg_sql::parse_function_body`.
10257pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10258    parse_plpgsql_body(body)
10259}
10260
10261fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10262    // Use the regular lexer on the body text. The trailing
10263    // `END;` may or may not have a semicolon; the lexer treats
10264    // both forms identically.
10265    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
10266        message: alloc::format!("plpgsql body lex error: {e}"),
10267        token_pos: 0,
10268    })?;
10269    let mut parser = Parser::new(tokens);
10270    parser.parse_plpgsql_block()
10271}
10272
10273#[cfg(test)]
10274mod tests {
10275    use super::*;
10276    use alloc::string::ToString;
10277
10278    fn parse(s: &str) -> Statement {
10279        parse_statement(s).expect("parse ok")
10280    }
10281
10282    // v7.37.43-T4 sentori cutover acceptance — `release`, `index`,
10283    // `tables`, `partition`, etc. are unreserved keywords per PG's
10284    // `pg_get_keywords()` and MUST be usable as column / table /
10285    // alias names. Pre-T4 every drop-in user whose schema had one
10286    // of these as a column name (sentori events.release, mailrs
10287    // messages.index in some forks) blew the parser up at CREATE
10288    // TABLE time with "expected identifier, got Release". The
10289    // generalisation lives in `unreserved_keyword_text` + the
10290    // `expect_ident_like` and `parse_atom` arms that consult it.
10291    #[test]
10292    fn release_usable_as_column_name_in_create_table() {
10293        let stmt =
10294            parse("CREATE TABLE events (id INT PRIMARY KEY, release TEXT NOT NULL, payload TEXT)");
10295        if let Statement::CreateTable(t) = stmt {
10296            let names: alloc::vec::Vec<&str> = t.columns.iter().map(|c| c.name.as_str()).collect();
10297            assert_eq!(names, alloc::vec!["id", "release", "payload"]);
10298        } else {
10299            panic!("expected CreateTable");
10300        }
10301    }
10302
10303    #[test]
10304    fn release_usable_as_column_ref_in_select_projection() {
10305        // The sentori `0003_partition_events.sql` INSERT-SELECT
10306        // walk references `release` in both column lists; the
10307        // projection-side use exercises `parse_atom`'s relaxed
10308        // identifier set.
10309        parse("SELECT id, release, payload FROM events WHERE id = 1");
10310    }
10311
10312    #[test]
10313    fn release_usable_as_column_ref_in_insert_column_list() {
10314        // INSERT INTO t (id, release, payload) VALUES (…)
10315        parse("INSERT INTO events (id, release, payload) VALUES (1, '1.0.0', 'data')");
10316    }
10317
10318    #[test]
10319    fn alter_column_drop_not_null_uses_keyword_drop_token() {
10320        // Sentori `0013_audit_tombstone.sql` issues
10321        // `ALTER TABLE … ALTER COLUMN x DROP NOT NULL`. The lexer
10322        // emits Token::Drop (not Ident("drop")); the parser must
10323        // accept both in the ALTER COLUMN sub-dispatch.
10324        parse("ALTER TABLE audit_logs ALTER COLUMN org_id DROP NOT NULL");
10325    }
10326
10327    #[test]
10328    fn create_index_accepts_parenthesised_expression_key() {
10329        // sentori `0040_events_bundle_idx.sql` shape — JSONB
10330        // expression index. Pre-T4 the parser bailed at the
10331        // inner `(` with "expected column ident or expression,
10332        // got LParen". The Token::LParen arm in CREATE INDEX
10333        // routes through the expression parser instead.
10334        parse(
10335            "CREATE INDEX IF NOT EXISTS events_bundle_id_idx \
10336             ON events ((payload->'bundle'->>'id'))",
10337        );
10338    }
10339
10340    // v7.30.2 (mailrs round-25 ask 2) — nesting / chain budgets must
10341    // surface as parse errors, never stack overflows (embed hosts
10342    // abort on overflow).
10343    #[test]
10344    fn nesting_budget_errors_cleanly() {
10345        let depth = MAX_NEST_DEPTH + 50;
10346        let sql = format!("SELECT {}1{}", "(".repeat(depth), ")".repeat(depth));
10347        let err = parse_statement(&sql).expect_err("must reject");
10348        assert!(err.message.contains("nests deeper"), "{err:?}");
10349        // Within budget still parses.
10350        let sql = format!("SELECT {}1{}", "(".repeat(48), ")".repeat(48));
10351        parse(&sql);
10352    }
10353
10354    #[test]
10355    fn binary_chain_budget_errors_cleanly() {
10356        let sql = format!("SELECT 1{}", " + 1".repeat(MAX_BINARY_CHAIN + 50));
10357        let err = parse_statement(&sql).expect_err("must reject");
10358        assert!(err.message.contains("chained binary"), "{err:?}");
10359        // Within budget still parses (chain depth ≤ budget is safe
10360        // for recursive eval/drop on 2 MiB stacks).
10361        let sql = format!("SELECT 1{}", " + 1".repeat(200));
10362        parse(&sql);
10363    }
10364
10365    #[test]
10366    fn in_list_unaffected_by_chain_budget() {
10367        // Flat InList: 20k elements parse fine and stay flat.
10368        let items: alloc::vec::Vec<String> = (0..20_000).map(|k| k.to_string()).collect();
10369        let sql = format!("SELECT 1 WHERE 5 IN ({})", items.join(","));
10370        let Statement::Select(s) = parse(&sql) else {
10371            panic!("expected select")
10372        };
10373        let Some(Expr::InList { list, negated, .. }) = s.where_ else {
10374            panic!("expected flat InList, got {:?}", s.where_)
10375        };
10376        assert_eq!(list.len(), 20_000);
10377        assert!(!negated);
10378    }
10379
10380    fn lit_int(n: i64) -> Expr {
10381        Expr::Literal(Literal::Integer(n))
10382    }
10383
10384    fn col(name: &str) -> Expr {
10385        Expr::Column(ColumnName {
10386            qualifier: None,
10387            name: name.into(),
10388        })
10389    }
10390
10391    #[test]
10392    fn select_single_integer() {
10393        let s = parse("SELECT 1");
10394        let Statement::Select(s) = s else {
10395            panic!("expected SELECT")
10396        };
10397        assert_eq!(s.items.len(), 1);
10398        assert!(s.from.is_none());
10399        assert!(s.where_.is_none());
10400    }
10401
10402    #[test]
10403    fn select_multiple_literal_kinds() {
10404        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
10405        let Statement::Select(s) = s else {
10406            panic!("expected SELECT")
10407        };
10408        assert_eq!(s.items.len(), 5);
10409    }
10410
10411    #[test]
10412    fn select_wildcard_from_table() {
10413        let s = parse("SELECT * FROM users");
10414        let Statement::Select(s) = s else {
10415            panic!("expected SELECT")
10416        };
10417        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
10418        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
10419    }
10420
10421    #[test]
10422    fn select_with_table_alias() {
10423        let s = parse("SELECT * FROM users AS u");
10424        let Statement::Select(s) = s else {
10425            panic!("expected SELECT")
10426        };
10427        let t = &s.from.as_ref().unwrap().primary;
10428        assert_eq!(t.name, "users");
10429        assert_eq!(t.alias.as_deref(), Some("u"));
10430    }
10431
10432    #[test]
10433    fn select_with_where_eq() {
10434        let s = parse("SELECT a FROM t WHERE a = 1");
10435        let Statement::Select(s) = s else {
10436            panic!("expected SELECT")
10437        };
10438        let w = s.where_.unwrap();
10439        assert_eq!(
10440            w,
10441            Expr::Binary {
10442                lhs: Box::new(col("a")),
10443                op: BinOp::Eq,
10444                rhs: Box::new(lit_int(1)),
10445            }
10446        );
10447    }
10448
10449    #[test]
10450    fn arithmetic_precedence() {
10451        let s = parse("SELECT 1 + 2 * 3");
10452        let Statement::Select(s) = s else {
10453            panic!("expected SELECT")
10454        };
10455        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10456            panic!("wildcard?")
10457        };
10458        assert_eq!(
10459            expr,
10460            &Expr::Binary {
10461                lhs: Box::new(lit_int(1)),
10462                op: BinOp::Add,
10463                rhs: Box::new(Expr::Binary {
10464                    lhs: Box::new(lit_int(2)),
10465                    op: BinOp::Mul,
10466                    rhs: Box::new(lit_int(3)),
10467                }),
10468            }
10469        );
10470    }
10471
10472    #[test]
10473    fn parentheses_override_precedence() {
10474        let s = parse("SELECT (1 + 2) * 3");
10475        let Statement::Select(s) = s else {
10476            panic!("expected SELECT")
10477        };
10478        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10479            panic!()
10480        };
10481        assert_eq!(
10482            expr,
10483            &Expr::Binary {
10484                lhs: Box::new(Expr::Binary {
10485                    lhs: Box::new(lit_int(1)),
10486                    op: BinOp::Add,
10487                    rhs: Box::new(lit_int(2)),
10488                }),
10489                op: BinOp::Mul,
10490                rhs: Box::new(lit_int(3)),
10491            }
10492        );
10493    }
10494
10495    #[test]
10496    fn not_binds_below_comparison() {
10497        // `NOT a = 1` should parse as `NOT (a = 1)`.
10498        let s = parse("SELECT NOT a = 1 FROM t");
10499        let Statement::Select(s) = s else {
10500            panic!("expected SELECT")
10501        };
10502        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10503            panic!()
10504        };
10505        assert_eq!(
10506            expr,
10507            &Expr::Unary {
10508                op: UnOp::Not,
10509                expr: Box::new(Expr::Binary {
10510                    lhs: Box::new(col("a")),
10511                    op: BinOp::Eq,
10512                    rhs: Box::new(lit_int(1)),
10513                }),
10514            }
10515        );
10516    }
10517
10518    #[test]
10519    fn unary_minus_binds_above_multiplication() {
10520        // `-a * 2` should be `(-a) * 2`.
10521        let s = parse("SELECT -a * 2 FROM t");
10522        let Statement::Select(s) = s else {
10523            panic!("expected SELECT")
10524        };
10525        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10526            panic!()
10527        };
10528        assert_eq!(
10529            expr,
10530            &Expr::Binary {
10531                lhs: Box::new(Expr::Unary {
10532                    op: UnOp::Neg,
10533                    expr: Box::new(col("a")),
10534                }),
10535                op: BinOp::Mul,
10536                rhs: Box::new(lit_int(2)),
10537            }
10538        );
10539    }
10540
10541    #[test]
10542    fn qualified_column() {
10543        let s = parse("SELECT t.col FROM t");
10544        let Statement::Select(s) = s else {
10545            panic!("expected SELECT")
10546        };
10547        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10548            panic!()
10549        };
10550        assert_eq!(
10551            expr,
10552            &Expr::Column(ColumnName {
10553                qualifier: Some("t".into()),
10554                name: "col".into()
10555            })
10556        );
10557    }
10558
10559    #[test]
10560    fn select_item_alias_with_as() {
10561        let s = parse("SELECT a AS y FROM t");
10562        let Statement::Select(s) = s else {
10563            panic!("expected SELECT")
10564        };
10565        let SelectItem::Expr { alias, .. } = &s.items[0] else {
10566            panic!()
10567        };
10568        assert_eq!(alias.as_deref(), Some("y"));
10569    }
10570
10571    #[test]
10572    fn trailing_semicolon_accepted() {
10573        let s = parse("SELECT 1;");
10574        let Statement::Select(s) = s else {
10575            panic!("expected SELECT")
10576        };
10577        assert_eq!(s.items.len(), 1);
10578    }
10579
10580    #[test]
10581    fn boolean_chain_with_and_or_not() {
10582        // (NOT a) OR (b AND (NOT c))
10583        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
10584        let Statement::Select(s) = s else {
10585            panic!("expected SELECT")
10586        };
10587        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10588            panic!()
10589        };
10590        let expected = Expr::Binary {
10591            lhs: Box::new(Expr::Unary {
10592                op: UnOp::Not,
10593                expr: Box::new(col("a")),
10594            }),
10595            op: BinOp::Or,
10596            rhs: Box::new(Expr::Binary {
10597                lhs: Box::new(col("b")),
10598                op: BinOp::And,
10599                rhs: Box::new(Expr::Unary {
10600                    op: UnOp::Not,
10601                    expr: Box::new(col("c")),
10602                }),
10603            }),
10604        };
10605        assert_eq!(expr, &expected);
10606    }
10607
10608    #[test]
10609    fn empty_input_errors() {
10610        // v7.14.0 — pg_dump preambles emit several comment-only
10611        // / blank-line statements that collapse to Statement::
10612        // Empty rather than a parse error. The old "SELECT in
10613        // message" assertion is stale; verify the new contract:
10614        // empty / whitespace / comment-only input parses to
10615        // Statement::Empty.
10616        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
10617        assert!(matches!(
10618            parse_statement("  \n\t ").unwrap(),
10619            Statement::Empty
10620        ));
10621        // Sanity: malformed-but-non-empty still errors.
10622        assert!(parse_statement("SELECT FROM WHERE").is_err());
10623    }
10624
10625    #[test]
10626    fn unmatched_paren_errors() {
10627        assert!(parse_statement("SELECT (1 + 2").is_err());
10628    }
10629
10630    #[test]
10631    fn display_round_trip_simple_select() {
10632        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
10633        let text = original.to_string();
10634        let again = parse_statement(&text).expect("re-parse");
10635        assert_eq!(original, again);
10636    }
10637
10638    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
10639
10640    #[test]
10641    fn create_table_single_column() {
10642        let s = parse("CREATE TABLE foo (a INT)");
10643        let Statement::CreateTable(c) = s else {
10644            panic!("expected CreateTable")
10645        };
10646        assert_eq!(c.name, "foo");
10647        assert_eq!(c.columns.len(), 1);
10648        assert_eq!(c.columns[0].name, "a");
10649        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10650        assert!(c.columns[0].nullable);
10651    }
10652
10653    #[test]
10654    fn create_table_multi_column_with_not_null_mix() {
10655        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
10656        let Statement::CreateTable(c) = s else {
10657            panic!()
10658        };
10659        assert_eq!(c.columns.len(), 4);
10660        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10661        assert!(!c.columns[0].nullable);
10662        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
10663        assert!(c.columns[1].nullable);
10664        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
10665        assert!(!c.columns[2].nullable);
10666        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
10667    }
10668
10669    #[test]
10670    fn create_table_bigint_supported() {
10671        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
10672        let Statement::CreateTable(c) = s else {
10673            panic!()
10674        };
10675        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
10676    }
10677
10678    #[test]
10679    fn create_table_vector_default_is_f32() {
10680        let s = parse("CREATE TABLE t (v VECTOR(128))");
10681        let Statement::CreateTable(c) = s else {
10682            panic!()
10683        };
10684        assert_eq!(
10685            c.columns[0].ty,
10686            ColumnTypeName::Vector {
10687                dim: 128,
10688                encoding: VecEncoding::F32,
10689            },
10690        );
10691    }
10692
10693    #[test]
10694    fn create_table_vector_using_sq8() {
10695        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
10696        // Case-insensitive on both `USING` and the encoding name.
10697        for sql in [
10698            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
10699            "CREATE TABLE t (v VECTOR(128) using sq8)",
10700        ] {
10701            let s = parse(sql);
10702            let Statement::CreateTable(c) = s else {
10703                panic!()
10704            };
10705            assert_eq!(
10706                c.columns[0].ty,
10707                ColumnTypeName::Vector {
10708                    dim: 128,
10709                    encoding: VecEncoding::Sq8,
10710                },
10711                "{sql}",
10712            );
10713        }
10714    }
10715
10716    #[test]
10717    fn create_table_vector_using_unknown_errors() {
10718        // v7.16.1 — the inline `USING <encoding>` shape on
10719        // CREATE TABLE column defs was withdrawn before
10720        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
10721        // (col vector_<metric>_ops)`; the parser now rejects
10722        // USING at column-list position with a clearer
10723        // "expected ',' or ')'" message. Test asserts the
10724        // current rejection, not the old "unknown vector
10725        // encoding" string.
10726        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
10727        assert!(
10728            err.message.contains("USING")
10729                || err.message.contains("using")
10730                || err.message.contains("')'")
10731                || err.message.contains("','"),
10732            "expected USING/column-list rejection, got: {}",
10733            err.message
10734        );
10735    }
10736
10737    #[test]
10738    fn vector_using_sq8_display_roundtrips() {
10739        // The Display impl must produce text that re-parses to the
10740        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
10741        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
10742        let Statement::CreateTable(c) = s else {
10743            panic!()
10744        };
10745        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
10746    }
10747
10748    #[test]
10749    fn parser_recognises_placeholders() {
10750        use crate::ast::{Expr, SelectItem, Statement};
10751        // $N in expression position parses as Expr::Placeholder(N).
10752        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
10753        let Statement::Select(sel) = s else { panic!() };
10754        assert!(matches!(
10755            sel.items[0],
10756            SelectItem::Expr {
10757                expr: Expr::Placeholder(1),
10758                alias: None
10759            }
10760        ));
10761        // $2 + 1
10762        let SelectItem::Expr {
10763            expr: Expr::Binary { lhs, rhs, .. },
10764            ..
10765        } = &sel.items[1]
10766        else {
10767            panic!()
10768        };
10769        assert!(matches!(**lhs, Expr::Placeholder(2)));
10770        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
10771        // WHERE x = $3
10772        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
10773            panic!()
10774        };
10775        assert!(matches!(**rhs, Expr::Placeholder(3)));
10776    }
10777
10778    #[test]
10779    fn parser_rejects_dollar_zero() {
10780        // $0 is not valid in PG; the lexer rejects it.
10781        assert!(parse_statement("SELECT $0").is_err());
10782    }
10783
10784    #[test]
10785    fn placeholder_display_roundtrips() {
10786        // The Display impl must produce text that re-lexes to the
10787        // same Placeholder token.
10788        let s = parse("SELECT $42 FROM t");
10789        let printed = s.to_string();
10790        assert!(printed.contains("$42"));
10791        let again = parse(&printed);
10792        assert_eq!(s, again);
10793    }
10794
10795    #[test]
10796    fn alter_index_rebuild_bare() {
10797        use crate::ast::{AlterIndexTarget, Statement};
10798        let s = parse("ALTER INDEX my_idx REBUILD");
10799        let Statement::AlterIndex(a) = s else {
10800            panic!("expected AlterIndex, got {s:?}")
10801        };
10802        assert_eq!(a.name, "my_idx");
10803        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
10804    }
10805
10806    #[test]
10807    fn alter_index_rebuild_with_encoding() {
10808        use crate::ast::{AlterIndexTarget, Statement};
10809        for (sql, want) in [
10810            (
10811                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
10812                VecEncoding::F32,
10813            ),
10814            (
10815                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
10816                VecEncoding::Sq8,
10817            ),
10818            (
10819                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10820                VecEncoding::F16,
10821            ),
10822        ] {
10823            let s = parse(sql);
10824            let Statement::AlterIndex(a) = s else {
10825                panic!("{sql}: expected AlterIndex")
10826            };
10827            assert_eq!(a.name, "my_idx");
10828            assert_eq!(
10829                a.target,
10830                AlterIndexTarget::Rebuild {
10831                    encoding: Some(want)
10832                },
10833                "{sql}"
10834            );
10835        }
10836    }
10837
10838    #[test]
10839    fn alter_index_rebuild_unknown_encoding_errors() {
10840        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
10841        assert!(
10842            err.message.contains("unknown vector encoding"),
10843            "got: {}",
10844            err.message
10845        );
10846    }
10847
10848    #[test]
10849    fn alter_index_rebuild_display_roundtrips() {
10850        for (input, want) in [
10851            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
10852            (
10853                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
10854                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
10855            ),
10856            (
10857                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10858                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10859            ),
10860        ] {
10861            let s = parse(input);
10862            assert_eq!(s.to_string(), want);
10863        }
10864    }
10865
10866    #[test]
10867    fn create_table_unknown_type_defers_to_engine() {
10868        // v4.9 picked XML as a parse-time "unsupported column
10869        // type" probe. v7.17.0 Phase 1.4 changed the contract:
10870        // an unknown type ident parses as Text + `user_type_ref`
10871        // so CREATE TABLE can resolve user-defined enum / domain
10872        // types — rejection of truly-unknown types moved to the
10873        // engine's catalog lookup. v7.37.5 ζ-A then promoted XML
10874        // to a first-class built-in, so this probe switched to a
10875        // synthetic name nothing in the lexer will ever recognise.
10876        let stmt = parse_statement("CREATE TABLE x (a my_user_type)").unwrap();
10877        let Statement::CreateTable(t) = stmt else {
10878            panic!("expected CreateTable");
10879        };
10880        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("my_user_type"));
10881    }
10882
10883    #[test]
10884    fn create_table_missing_table_keyword_errors() {
10885        assert!(parse_statement("CREATE x (a INT)").is_err());
10886    }
10887
10888    // v7.37.6-B(sentori Epic 2 P0)— `PARTITION BY RANGE` parent +
10889    // `PARTITION OF parent <bounds>` child parse + Display round-trip.
10890
10891    #[test]
10892    fn parse_create_table_partition_by_range() {
10893        use crate::ast::{PartitionBySpec, PartitionKindAst};
10894        let stmt = parse_statement(
10895            "CREATE TABLE events_partitioned (id BIGINT NOT NULL, ts TIMESTAMPTZ NOT NULL, \
10896             payload JSONB) PARTITION BY RANGE (ts)",
10897        )
10898        .unwrap();
10899        let Statement::CreateTable(t) = stmt else {
10900            panic!("expected CreateTable");
10901        };
10902        assert!(t.partition_of.is_none(), "parent has no partition_of");
10903        assert_eq!(t.columns.len(), 3);
10904        let by = t.partition_by.as_ref().expect("expected PARTITION BY");
10905        assert_eq!(
10906            by,
10907            &PartitionBySpec {
10908                kind: PartitionKindAst::Range,
10909                key_columns: alloc::vec!["ts".to_string()],
10910            }
10911        );
10912        // Display round-trip preserves the suffix. `quote_ident`
10913        // only adds double quotes when the ident needs escaping, so
10914        // a plain `ts` survives bare here.
10915        assert!(
10916            t.to_string().contains("PARTITION BY RANGE (ts)"),
10917            "Display lost PARTITION BY suffix: {t}"
10918        );
10919    }
10920
10921    #[test]
10922    fn parse_create_table_partition_of_range() {
10923        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
10924        let stmt = parse_statement(
10925            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned \
10926             FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00')",
10927        )
10928        .unwrap();
10929        let Statement::CreateTable(t) = stmt else {
10930            panic!("expected CreateTable");
10931        };
10932        assert!(t.columns.is_empty(), "child inherits columns from parent");
10933        assert!(t.partition_by.is_none());
10934        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
10935        assert_eq!(of.parent_name, "events_partitioned");
10936        let PartitionOfSpec { bounds, .. } = of.clone();
10937        match bounds {
10938            PartitionOfBoundsAst::Range { lower, upper } => {
10939                assert!(lower.to_string().contains("2026-06-01"));
10940                assert!(upper.to_string().contains("2026-07-01"));
10941            }
10942            PartitionOfBoundsAst::Default => panic!("expected Range, got Default"),
10943        }
10944        // Display round-trip emits the FOR VALUES tail. `quote_ident`
10945        // skips quotes when not required, so the parent name appears
10946        // bare here.
10947        let s = t.to_string();
10948        assert!(
10949            s.contains("PARTITION OF events_partitioned"),
10950            "Display lost PARTITION OF: {s}"
10951        );
10952        assert!(s.contains("FOR VALUES FROM"), "Display lost FROM: {s}");
10953        assert!(s.contains(") TO ("), "Display lost TO: {s}");
10954    }
10955
10956    #[test]
10957    fn parse_create_table_partition_of_default() {
10958        use crate::ast::PartitionOfBoundsAst;
10959        let stmt =
10960            parse_statement("CREATE TABLE events_default PARTITION OF events_partitioned DEFAULT")
10961                .unwrap();
10962        let Statement::CreateTable(t) = stmt else {
10963            panic!("expected CreateTable");
10964        };
10965        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
10966        assert_eq!(of.parent_name, "events_partitioned");
10967        assert!(matches!(of.bounds, PartitionOfBoundsAst::Default));
10968        assert!(
10969            t.to_string()
10970                .contains("PARTITION OF events_partitioned DEFAULT"),
10971            "Display lost DEFAULT: {t}"
10972        );
10973    }
10974
10975    #[test]
10976    fn parse_create_table_partition_of_rejects_columns() {
10977        // v7.37.6-B contract: PARTITION OF children inherit columns
10978        // from the parent; an explicit list MUST surface as a parse
10979        // error rather than getting silently ignored.
10980        let err = parse_statement(
10981            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned (id BIGINT) \
10982             FOR VALUES FROM ('a') TO ('b')",
10983        );
10984        assert!(err.is_err(), "expected parse error for explicit columns");
10985        let msg = format!("{}", err.unwrap_err());
10986        assert!(
10987            msg.contains("PARTITION OF") && msg.contains("column"),
10988            "error should mention PARTITION OF + columns: {msg}"
10989        );
10990    }
10991
10992    #[test]
10993    fn insert_single_value() {
10994        let s = parse("INSERT INTO foo VALUES (42)");
10995        let Statement::Insert(i) = s else {
10996            panic!("expected Insert")
10997        };
10998        assert_eq!(i.table, "foo");
10999        assert_eq!(i.rows.len(), 1);
11000        assert_eq!(i.rows[0].len(), 1);
11001        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
11002    }
11003
11004    #[test]
11005    fn insert_multi_value_with_mixed_literals() {
11006        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
11007        let Statement::Insert(i) = s else { panic!() };
11008        assert_eq!(i.rows.len(), 1);
11009        assert_eq!(i.rows[0].len(), 5);
11010    }
11011
11012    #[test]
11013    fn insert_missing_into_errors() {
11014        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
11015    }
11016
11017    #[test]
11018    fn create_table_round_trip() {
11019        let original =
11020            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
11021        let text = original.to_string();
11022        let again = parse_statement(&text).expect("re-parse");
11023        assert_eq!(original, again);
11024    }
11025
11026    #[test]
11027    fn insert_round_trip_with_negation_and_string() {
11028        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
11029        let text = original.to_string();
11030        let again = parse_statement(&text).expect("re-parse");
11031        assert_eq!(original, again);
11032    }
11033
11034    #[test]
11035    fn unknown_keyword_at_statement_start_errors() {
11036        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
11037        // the top-level dispatch still has no branch to take.
11038        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
11039        assert!(err.message.contains("expected SELECT"));
11040    }
11041
11042    // --- v0.8 CREATE INDEX --------------------------------------------------
11043
11044    #[test]
11045    fn create_index_basic() {
11046        let s = parse("CREATE INDEX idx_id ON users (id)");
11047        let Statement::CreateIndex(c) = s else {
11048            panic!("expected CreateIndex")
11049        };
11050        assert_eq!(c.name, "idx_id");
11051        assert_eq!(c.table, "users");
11052        assert_eq!(c.column, "id");
11053    }
11054
11055    #[test]
11056    fn create_index_missing_on_errors() {
11057        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
11058    }
11059
11060    #[test]
11061    fn create_index_missing_paren_errors() {
11062        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
11063    }
11064
11065    #[test]
11066    fn create_index_round_trip() {
11067        let original = parse("CREATE INDEX by_name ON users (name)");
11068        let again = parse_statement(&original.to_string()).unwrap();
11069        assert_eq!(original, again);
11070    }
11071
11072    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
11073
11074    #[test]
11075    fn create_unique_index_basic() {
11076        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
11077        let Statement::CreateIndex(c) = s else {
11078            panic!("expected CreateIndex");
11079        };
11080        assert!(c.is_unique);
11081        assert_eq!(c.column, "a");
11082        assert!(c.partial_predicate.is_none());
11083    }
11084
11085    #[test]
11086    fn create_unique_index_partial() {
11087        // mailrs's email_templates "one default per user" shape.
11088        let s = parse(
11089            "CREATE UNIQUE INDEX idx_email_templates_user_default \
11090             ON email_templates (user_address) WHERE is_default = true",
11091        );
11092        let Statement::CreateIndex(c) = s else {
11093            panic!("expected CreateIndex");
11094        };
11095        assert!(c.is_unique);
11096        assert_eq!(c.table, "email_templates");
11097        assert_eq!(c.column, "user_address");
11098        assert!(c.partial_predicate.is_some());
11099    }
11100
11101    #[test]
11102    fn create_unique_index_composite_with_predicate() {
11103        // mailrs's calendar_events instance: composite columns.
11104        let s = parse(
11105            "CREATE UNIQUE INDEX uq_calendar_events_instance \
11106             ON calendar_events (calendar_id, uid, recurrence_id) \
11107             WHERE recurrence_id IS NOT NULL",
11108        );
11109        let Statement::CreateIndex(c) = s else {
11110            panic!("expected CreateIndex");
11111        };
11112        assert!(c.is_unique);
11113        assert_eq!(c.column, "calendar_id");
11114        assert_eq!(
11115            c.extra_columns,
11116            vec!["uid".to_string(), "recurrence_id".to_string()]
11117        );
11118        assert!(c.partial_predicate.is_some());
11119    }
11120
11121    #[test]
11122    fn create_unique_index_using_btree_ok() {
11123        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
11124        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
11125    }
11126
11127    #[test]
11128    fn create_unique_index_using_hnsw_rejected() {
11129        let err =
11130            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
11131        assert!(err.message.contains("UNIQUE"), "{}", err.message);
11132    }
11133
11134    #[test]
11135    fn create_unique_index_round_trip() {
11136        let original = parse(
11137            "CREATE UNIQUE INDEX uq_calendar_events_master \
11138             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
11139        );
11140        let again = parse_statement(&original.to_string()).unwrap();
11141        assert_eq!(original, again);
11142    }
11143
11144    #[test]
11145    fn create_unique_without_index_errors() {
11146        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
11147        assert!(err.message.contains("INDEX"), "{}", err.message);
11148    }
11149
11150    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
11151
11152    #[test]
11153    fn create_table_bytea_column() {
11154        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
11155        let Statement::CreateTable(c) = s else {
11156            panic!("expected CreateTable");
11157        };
11158        assert_eq!(c.columns.len(), 2);
11159        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
11160        assert!(!c.columns[1].nullable);
11161    }
11162
11163    #[test]
11164    fn create_table_bytes_alias_column() {
11165        let s = parse("CREATE TABLE t (blob BYTES)");
11166        let Statement::CreateTable(c) = s else {
11167            panic!("expected CreateTable");
11168        };
11169        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
11170    }
11171
11172    #[test]
11173    fn bytea_round_trip_display() {
11174        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
11175        let again = parse_statement(&original.to_string()).unwrap();
11176        assert_eq!(original, again);
11177    }
11178
11179    // --- v0.9 transactions -------------------------------------------------
11180
11181    #[test]
11182    fn begin_commit_rollback_parse_as_unit_variants() {
11183        assert_eq!(parse("BEGIN"), Statement::Begin);
11184        assert_eq!(parse("COMMIT"), Statement::Commit);
11185        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
11186        // Trailing semicolons accepted too.
11187        assert_eq!(parse("BEGIN;"), Statement::Begin);
11188    }
11189
11190    // --- v1.2: pgvector distance ops + ::vector cast --------------------
11191
11192    #[test]
11193    fn inner_product_binop_parses() {
11194        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
11195        let Statement::Select(s) = s else { panic!() };
11196        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11197            panic!()
11198        };
11199        assert!(matches!(
11200            expr,
11201            Expr::Binary {
11202                op: BinOp::InnerProduct,
11203                ..
11204            }
11205        ));
11206    }
11207
11208    #[test]
11209    fn cosine_distance_binop_parses() {
11210        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
11211        let Statement::Select(s) = s else { panic!() };
11212        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11213            panic!()
11214        };
11215        assert!(matches!(
11216            expr,
11217            Expr::Binary {
11218                op: BinOp::CosineDistance,
11219                ..
11220            }
11221        ));
11222    }
11223
11224    #[test]
11225    fn vector_cast_postfix_wraps_string_literal() {
11226        let s = parse("SELECT '[1,2,3]'::vector FROM t");
11227        let Statement::Select(s) = s else { panic!() };
11228        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11229            panic!()
11230        };
11231        assert!(matches!(
11232            expr,
11233            Expr::Cast {
11234                target: CastTarget::Vector,
11235                ..
11236            }
11237        ));
11238    }
11239
11240    #[test]
11241    fn unsupported_cast_target_errors() {
11242        // v7.37.5 ship triage promoted the parser to accept every
11243        // ident as a `CastTarget::Named(canonical)`; the engine
11244        // surfaces the "unsupported cast target" error at eval
11245        // time when `type_name_to_data_type` can't resolve it.
11246        // Parser-side error now requires a NON-ident after `::`
11247        // (e.g. a punctuation token).
11248        let err = parse_statement("SELECT 1::, FROM t").unwrap_err();
11249        assert!(err.message.contains("expected type ident after `::`"));
11250    }
11251
11252    #[test]
11253    fn tx_statements_round_trip() {
11254        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
11255            let original = parse(q);
11256            let again = parse_statement(&original.to_string()).unwrap();
11257            assert_eq!(original, again);
11258        }
11259    }
11260
11261    #[test]
11262    fn interval_text_parsing_units() {
11263        // v7.37.5 β — three-field shape `(months, days, micros)` so
11264        // `'1 day'` and `'24 hours'` no longer collide (PG parity).
11265        // Single unit.
11266        assert_eq!(parse_interval_text("1 day"), Some((0, 1, 0)));
11267        assert_eq!(
11268            parse_interval_text("24 hours"),
11269            Some((0, 0, 86_400_000_000))
11270        );
11271        assert_eq!(parse_interval_text("1 second"), Some((0, 0, 1_000_000)));
11272        assert_eq!(parse_interval_text("1 month"), Some((1, 0, 0)));
11273        assert_eq!(parse_interval_text("2 years"), Some((24, 0, 0)));
11274        assert_eq!(parse_interval_text("1 week"), Some((0, 7, 0)));
11275        // Compound spans accumulate per-dimension.
11276        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0, 0)));
11277        assert_eq!(
11278            parse_interval_text("1 day 2 hours"),
11279            Some((0, 1, 7_200_000_000))
11280        );
11281        // Negative numbers carry through per-dimension.
11282        assert_eq!(parse_interval_text("-1 day"), Some((0, -1, 0)));
11283        // Bad shapes return None.
11284        assert_eq!(parse_interval_text(""), None);
11285        assert_eq!(parse_interval_text("garbage"), None);
11286        assert_eq!(parse_interval_text("1 fortnight"), None);
11287        assert_eq!(parse_interval_text("1"), None);
11288    }
11289
11290    #[test]
11291    fn interval_literal_roundtrips_via_display() {
11292        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
11293        let s = parsed.to_string();
11294        // Display preserves the original text verbatim.
11295        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
11296        // And re-parsing yields a structurally equal statement.
11297        let again = parse_statement(&s).unwrap();
11298        assert_eq!(parsed, again);
11299    }
11300
11301    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
11302
11303    #[test]
11304    fn parser_recognises_create_publication_bare() {
11305        let s = parse("CREATE PUBLICATION pub_a");
11306        let Statement::CreatePublication(p) = s else {
11307            panic!("expected CreatePublication, got {s:?}")
11308        };
11309        assert_eq!(p.name, "pub_a");
11310        assert_eq!(p.scope, PublicationScope::AllTables);
11311    }
11312
11313    #[test]
11314    fn parser_recognises_create_publication_for_all_tables() {
11315        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
11316        let Statement::CreatePublication(p) = s else {
11317            panic!("expected CreatePublication, got {s:?}")
11318        };
11319        assert_eq!(p.name, "pub_a");
11320        assert_eq!(p.scope, PublicationScope::AllTables);
11321    }
11322
11323    #[test]
11324    fn parser_recognises_drop_publication() {
11325        let s = parse("DROP PUBLICATION pub_a");
11326        let Statement::DropPublication(name) = s else {
11327            panic!("expected DropPublication, got {s:?}")
11328        };
11329        assert_eq!(name, "pub_a");
11330    }
11331
11332    #[test]
11333    fn parser_recognises_for_table_list() {
11334        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
11335        let Statement::CreatePublication(p) = s else {
11336            panic!("expected CreatePublication, got {s:?}")
11337        };
11338        assert_eq!(p.name, "pub_a");
11339        let PublicationScope::ForTables(ts) = p.scope else {
11340            panic!("expected ForTables scope")
11341        };
11342        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
11343    }
11344
11345    #[test]
11346    fn parser_recognises_for_tables_plural() {
11347        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
11348        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
11349        let Statement::CreatePublication(p) = s else {
11350            panic!("expected CreatePublication, got {s:?}")
11351        };
11352        let PublicationScope::ForTables(ts) = p.scope else {
11353            panic!("expected ForTables")
11354        };
11355        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11356    }
11357
11358    #[test]
11359    fn parser_recognises_for_all_tables_except_list() {
11360        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
11361        let Statement::CreatePublication(p) = s else {
11362            panic!()
11363        };
11364        let PublicationScope::AllTablesExcept(ts) = p.scope else {
11365            panic!("expected AllTablesExcept")
11366        };
11367        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11368    }
11369
11370    #[test]
11371    fn parser_rejects_for_table_with_empty_list() {
11372        // `FOR TABLE` with nothing after is a parse error.
11373        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
11374            .expect_err("must error on empty list");
11375        // No specific message asserted — the call falls through to
11376        // expect_ident_like which yields "expected identifier, got …".
11377        assert!(!err.message.is_empty());
11378    }
11379
11380    #[test]
11381    fn parser_recognises_show_publications() {
11382        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
11383        // bare ident in this position, NOT a reserved keyword.
11384        let s = parse("SHOW PUBLICATIONS");
11385        assert!(matches!(s, Statement::ShowPublications));
11386    }
11387
11388    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
11389
11390    #[test]
11391    fn parser_recognises_create_subscription_single_publication() {
11392        let s = parse(
11393            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
11394        );
11395        let Statement::CreateSubscription(c) = s else {
11396            panic!("expected CreateSubscription, got {s:?}")
11397        };
11398        assert_eq!(c.name, "sub_a");
11399        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
11400        assert_eq!(c.publications, alloc::vec!["pub_a"]);
11401    }
11402
11403    #[test]
11404    fn parser_recognises_create_subscription_multi_publication() {
11405        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
11406        let Statement::CreateSubscription(c) = s else {
11407            panic!()
11408        };
11409        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
11410    }
11411
11412    #[test]
11413    fn parser_rejects_create_subscription_missing_connection() {
11414        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
11415            .expect_err("must error on missing CONNECTION");
11416        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
11417    }
11418
11419    #[test]
11420    fn parser_rejects_create_subscription_missing_publication() {
11421        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
11422            .expect_err("must error on missing PUBLICATION");
11423        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
11424    }
11425
11426    #[test]
11427    fn parser_recognises_drop_subscription() {
11428        let s = parse("DROP SUBSCRIPTION sub_a");
11429        let Statement::DropSubscription(name) = s else {
11430            panic!("expected DropSubscription, got {s:?}")
11431        };
11432        assert_eq!(name, "sub_a");
11433    }
11434
11435    #[test]
11436    fn parser_recognises_show_subscriptions() {
11437        let s = parse("SHOW SUBSCRIPTIONS");
11438        assert!(matches!(s, Statement::ShowSubscriptions));
11439    }
11440
11441    #[test]
11442    fn parser_recognises_wait_for_wal_position_no_timeout() {
11443        let s = parse("WAIT FOR WAL POSITION 12345");
11444        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11445            panic!("expected WaitForWalPosition, got {s:?}")
11446        };
11447        assert_eq!(pos, 12345);
11448        assert!(timeout_ms.is_none());
11449    }
11450
11451    #[test]
11452    fn parser_recognises_wait_for_wal_position_with_timeout() {
11453        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
11454        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11455            panic!()
11456        };
11457        assert_eq!(pos, 67890);
11458        assert_eq!(timeout_ms, Some(5000));
11459    }
11460
11461    #[test]
11462    fn parser_rejects_wait_with_negative_position() {
11463        // The lexer treats `-` as a token; `expect_u64_literal`
11464        // only sees the Integer that follows, so the negative
11465        // arrives as a unary-minus expression at higher levels.
11466        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
11467        // parse error one way or another.
11468        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
11469        assert!(!err.message.is_empty());
11470    }
11471
11472    #[test]
11473    fn parser_recognises_bare_analyze() {
11474        let s = parse("ANALYZE");
11475        assert!(matches!(s, Statement::Analyze(None)));
11476    }
11477
11478    #[test]
11479    fn parser_recognises_analyze_with_table() {
11480        let s = parse("ANALYZE users");
11481        let Statement::Analyze(Some(name)) = s else {
11482            panic!("expected Analyze, got {s:?}")
11483        };
11484        assert_eq!(name, "users");
11485    }
11486
11487    #[test]
11488    fn parser_recognises_analyze_with_quoted_table() {
11489        let s = parse("ANALYZE \"Mixed Case\"");
11490        let Statement::Analyze(Some(name)) = s else {
11491            panic!()
11492        };
11493        assert_eq!(name, "Mixed Case");
11494    }
11495
11496    #[test]
11497    fn parser_rejects_analyze_with_garbage_token() {
11498        let err = parse_statement("ANALYZE 42").expect_err("must error");
11499        assert!(!err.message.is_empty());
11500    }
11501
11502    #[test]
11503    fn analyze_display_roundtrips() {
11504        for sql in ["ANALYZE", "ANALYZE users"] {
11505            let s = parse(sql);
11506            let printed = s.to_string();
11507            let again = parse_statement(&printed)
11508                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11509            assert_eq!(s, again);
11510        }
11511    }
11512
11513    #[test]
11514    fn wait_for_display_roundtrips() {
11515        for sql in [
11516            "WAIT FOR WAL POSITION 12345",
11517            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
11518        ] {
11519            let s = parse(sql);
11520            let printed = s.to_string();
11521            let again = parse_statement(&printed)
11522                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11523            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11524        }
11525    }
11526
11527    #[test]
11528    fn subscription_ddl_display_roundtrips() {
11529        for sql in [
11530            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
11531            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
11532            "DROP SUBSCRIPTION sub_a",
11533            "SHOW SUBSCRIPTIONS",
11534        ] {
11535            let s = parse(sql);
11536            let printed = s.to_string();
11537            let again = parse_statement(&printed)
11538                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11539            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11540        }
11541    }
11542
11543    #[test]
11544    fn parser_drop_dispatches_user_vs_publication() {
11545        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
11546        // tokenises DROP. Both targets must still parse.
11547        let s = parse("DROP USER 'alice'");
11548        let Statement::DropUser(name) = s else {
11549            panic!("expected DropUser, got {s:?}")
11550        };
11551        assert_eq!(name, "alice");
11552        // And DROP PUBLICATION lands the new variant.
11553        let s = parse("DROP PUBLICATION p1");
11554        assert!(matches!(s, Statement::DropPublication(_)));
11555    }
11556
11557    #[test]
11558    fn publication_ddl_display_roundtrips() {
11559        // Every CREATE PUBLICATION variant must Display → parse →
11560        // same AST. v6.1.3 covers all three scope shapes.
11561        for sql in [
11562            "CREATE PUBLICATION pub_a",
11563            "CREATE PUBLICATION pub_a FOR ALL TABLES",
11564            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
11565            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
11566            "DROP PUBLICATION pub_a",
11567            "SHOW PUBLICATIONS",
11568        ] {
11569            let s = parse(sql);
11570            let printed = s.to_string();
11571            let again = parse_statement(&printed)
11572                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11573            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11574        }
11575    }
11576
11577    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
11578
11579    #[test]
11580    fn create_function_returns_trigger_plpgsql_minimal() {
11581        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
11582        let s = parse(sql);
11583        let Statement::CreateFunction(f) = s else {
11584            panic!("expected CreateFunction");
11585        };
11586        assert_eq!(f.name, "noop");
11587        assert!(!f.or_replace);
11588        assert!(f.args.is_empty());
11589        assert!(matches!(f.returns, FunctionReturn::Trigger));
11590        assert_eq!(f.language, "plpgsql");
11591        let FunctionBody::PlPgSql(block) = f.body else {
11592            panic!("expected PlPgSql body");
11593        };
11594        assert_eq!(block.statements.len(), 1);
11595        assert!(matches!(
11596            block.statements[0],
11597            PlPgSqlStmt::Return(ReturnTarget::New)
11598        ));
11599    }
11600
11601    #[test]
11602    fn create_function_or_replace_with_assignment() {
11603        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
11604        // RETURN NEW.
11605        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
11606BEGIN
11607  NEW.search_vector := to_tsvector('english', NEW.subject);
11608  RETURN NEW;
11609END;
11610$$";
11611        let s = parse(sql);
11612        let Statement::CreateFunction(f) = s else {
11613            panic!("expected CreateFunction");
11614        };
11615        assert!(f.or_replace);
11616        let FunctionBody::PlPgSql(block) = &f.body else {
11617            panic!("expected PlPgSql body");
11618        };
11619        assert_eq!(block.statements.len(), 2);
11620        // First statement: NEW.search_vector := to_tsvector(...)
11621        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
11622            panic!("expected Assign as first stmt");
11623        };
11624        match target {
11625            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
11626            other => panic!("expected NEW.col, got {other:?}"),
11627        }
11628        // Second statement: RETURN NEW
11629        assert!(matches!(
11630            block.statements[1],
11631            PlPgSqlStmt::Return(ReturnTarget::New)
11632        ));
11633    }
11634
11635    #[test]
11636    fn create_trigger_after_insert_or_update() {
11637        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
11638        let s = parse(sql);
11639        let Statement::CreateTrigger(t) = s else {
11640            panic!("expected CreateTrigger");
11641        };
11642        assert_eq!(t.name, "tg");
11643        assert_eq!(t.table, "messages");
11644        assert_eq!(t.timing, TriggerTiming::After);
11645        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
11646        assert_eq!(t.for_each, TriggerForEach::Row);
11647        assert_eq!(t.function, "update_sv");
11648    }
11649
11650    #[test]
11651    fn create_trigger_before_delete_execute_procedure_alias() {
11652        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
11653        let sql =
11654            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
11655        let s = parse(sql);
11656        let Statement::CreateTrigger(t) = s else {
11657            panic!("expected CreateTrigger");
11658        };
11659        assert_eq!(t.timing, TriggerTiming::Before);
11660        assert_eq!(t.events, vec![TriggerEvent::Delete]);
11661    }
11662
11663    #[test]
11664    fn drop_trigger_if_exists_round_trips() {
11665        // No parser support for DROP TRIGGER yet — added in v7.12.5
11666        // alongside the broader DROP …{IF EXISTS} cleanup. The
11667        // AST + Display impls are in place so we round-trip via
11668        // construction:
11669        let s = Statement::DropTrigger {
11670            name: "tg".into(),
11671            table: "messages".into(),
11672            if_exists: true,
11673        };
11674        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
11675    }
11676
11677    #[test]
11678    fn trigger_ddl_display_roundtrips_through_parser() {
11679        // CREATE TRIGGER + its referenced CREATE FUNCTION must
11680        // Display → parse → same AST (modulo PL/pgSQL body
11681        // formatting which is parser-canonicalised).
11682        for sql in [
11683            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
11684            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
11685        ] {
11686            let s = parse(sql);
11687            let printed = s.to_string();
11688            let again = parse_statement(&printed)
11689                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11690            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11691        }
11692    }
11693}