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    IsolationLevel, JoinKind, Literal, NullTreatment, OrderBy, PlPgSqlBlock, PlPgSqlDeclare,
27    PlPgSqlStmt, PublicationScope, RaiseLevel, RangeKindAst, ReturnTarget, SelectItem,
28    SelectStatement, Statement, TableRef, TriggerEvent, TriggerForEach, TriggerTiming, UnOp,
29    UnionKind, VecEncoding, 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                // v7.38 轴 4 — PG-standard `BEGIN [WORK|TRANSACTION]
602                // [ISOLATION LEVEL …] [READ ONLY|WRITE]
603                // [[NOT] DEFERRABLE]`. We accept the optional
604                // TRANSACTION/WORK noise word and parse-and-ignore
605                // trailing iso modes (parser doesn't reject the
606                // syntax; the iso level is only honoured when set
607                // via the dedicated `SET TRANSACTION` statement
608                // until the v7.38 isolation framework lands a
609                // per-TX level field on the engine).
610                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("work") || s.eq_ignore_ascii_case("transaction"))
611                {
612                    self.advance();
613                    // Parse-and-ignore any trailing modes.
614                    let _ = self.parse_isolation_level_clauses()?;
615                }
616                Ok(Statement::Begin)
617            }
618            // v7.38 轴 4 — PG-standard `START TRANSACTION …` synonym
619            // for BEGIN. START is contextual in PG too; pattern-match
620            // on the ident here. Iso clauses are parse-and-ignored,
621            // same as BEGIN above.
622            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("start") => {
623                self.advance();
624                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("transaction"))
625                {
626                    return Err(self.err(alloc::format!(
627                        "expected TRANSACTION after START, got {:?}",
628                        self.peek()
629                    )));
630                }
631                self.advance();
632                let _ = self.parse_isolation_level_clauses()?;
633                Ok(Statement::Begin)
634            }
635            Token::Commit => {
636                self.advance();
637                Ok(Statement::Commit)
638            }
639            Token::Rollback => {
640                self.advance();
641                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
642                // savepoint without ending the transaction. Bare
643                // `ROLLBACK` drops the whole TX.
644                if matches!(self.peek(), Token::To) {
645                    self.advance();
646                    if matches!(self.peek(), Token::Savepoint) {
647                        self.advance();
648                    }
649                    let name = self.expect_ident_like()?;
650                    Ok(Statement::RollbackToSavepoint(name))
651                } else {
652                    Ok(Statement::Rollback)
653                }
654            }
655            Token::Savepoint => {
656                self.advance();
657                let name = self.expect_ident_like()?;
658                Ok(Statement::Savepoint(name))
659            }
660            Token::Release => {
661                self.advance();
662                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
663                // is optional in standard SQL.
664                if matches!(self.peek(), Token::Savepoint) {
665                    self.advance();
666                }
667                let name = self.expect_ident_like()?;
668                Ok(Statement::ReleaseSavepoint(name))
669            }
670            Token::Show => {
671                self.advance();
672                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
673                // v6.1.2 promoted TABLES to a reserved keyword (for
674                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
675                // arrives as `Token::Tables` rather than a bare ident.
676                // USERS / COLUMNS remain bare idents.
677                let target = match self.advance() {
678                    Token::Tables => "tables".to_string(),
679                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
680                    // keyword token; recognise it as the SHOW CREATE
681                    // dispatch keyword too.
682                    Token::Create => "create".to_string(),
683                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
684                    // keyword too; let SHOW INDEX FROM parse.
685                    Token::Index => "index".to_string(),
686                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
687                    other => {
688                        return Err(self.err(format!(
689                            "expected SHOW target, got {other:?}"
690                        )));
691                    }
692                };
693                match target.as_str() {
694                    "tables" => Ok(Statement::ShowTables),
695                    "users" => Ok(Statement::ShowUsers),
696                    // v7.38 轴 4 — `SHOW transaction_isolation`
697                    // returns the currently-selected isolation level.
698                    "transaction_isolation" => Ok(Statement::ShowParameter(
699                        "transaction_isolation".to_string(),
700                    )),
701                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
702                    // TABLE <t>` returns a 2-column row: (Table,
703                    // Create Table). mysqldump emits this for every
704                    // table at scrape time; without it the dump
705                    // round-trip stalls.
706                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
707                    // FROM <t>` (also spelled `SHOW INDEX` and
708                    // `SHOW KEYS`). admin / mysqldump probes use
709                    // it to list per-table indexes.
710                    "indexes" | "index" | "keys" => {
711                        if !matches!(self.peek(), Token::From) {
712                            return Err(self.err(format!(
713                                "expected FROM after SHOW INDEXES, got {:?}",
714                                self.peek()
715                            )));
716                        }
717                        self.advance();
718                        let table = self.expect_ident_like()?;
719                        Ok(Statement::ShowIndexes(table))
720                    }
721                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
722                    // `SHOW VARIABLES`. Both return a 2-column row
723                    // set listing server-side state; clients probe
724                    // them at connect time.
725                    "status" => Ok(Statement::ShowStatus),
726                    "variables" => Ok(Statement::ShowVariables),
727                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
728                    "processlist" => Ok(Statement::ShowProcesslist),
729                    "create" => {
730                        // SHOW CREATE TABLE / VIEW / DATABASE — only
731                        // TABLE is supported in v7.17.
732                        let kind = match self.advance() {
733                            Token::Ident(s) | Token::QuotedIdent(s) => s,
734                            Token::Table => "table".to_string(),
735                            other => {
736                                return Err(self.err(format!(
737                                    "expected TABLE after SHOW CREATE, got {other:?}"
738                                )));
739                            }
740                        };
741                        if !kind.eq_ignore_ascii_case("table") {
742                            return Err(self.err(format!(
743                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
744                            )));
745                        }
746                        let name = self.expect_ident_like()?;
747                        Ok(Statement::ShowCreateTable(name))
748                    }
749                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
750                    // (and `SHOW SCHEMAS` alias). The mysql client uses
751                    // it to populate the database selector at connect
752                    // time; without it `mysql -p` errors before the
753                    // first user query.
754                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
755                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
756                    // keyword on its own; it lands here as a bare
757                    // ident. Returning all publications + their
758                    // scope summary.
759                    "publications" => Ok(Statement::ShowPublications),
760                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
761                    "subscriptions" => Ok(Statement::ShowSubscriptions),
762                    "columns" => {
763                        if !matches!(self.peek(), Token::From) {
764                            return Err(self.err(format!(
765                                "expected FROM after SHOW COLUMNS, got {:?}",
766                                self.peek()
767                            )));
768                        }
769                        self.advance();
770                        let table = self.expect_ident_like()?;
771                        Ok(Statement::ShowColumns(table))
772                    }
773                    // v7.38 轴 4 surface — `SHOW <param>` for any
774                    // remaining session / preset parameter name
775                    // (server_version, search_path, client_encoding,
776                    // …). The engine's ShowParameter handler does the
777                    // dispatch; unrecognised names error there with
778                    // a pointer to pg_settings, not at parse time —
779                    // so a driver that issues `SHOW spam_setting`
780                    // gets a clear runtime error instead of a
781                    // confusing "unknown SHOW target".
782                    other => Ok(Statement::ShowParameter(other.to_string())),
783                }
784            }
785            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
786            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
787            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
788            // arrived as a bare ident; tokenising it dedicatedly
789            // keeps the dispatch tree small.
790            Token::Drop => {
791                self.advance();
792                match self.peek() {
793                    Token::Publication => {
794                        self.advance();
795                        let name = self.expect_ident_or_string()?;
796                        Ok(Statement::DropPublication(name))
797                    }
798                    Token::Subscription => {
799                        self.advance();
800                        let name = self.expect_ident_or_string()?;
801                        Ok(Statement::DropSubscription(name))
802                    }
803                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
804                        self.advance();
805                        let name = self.expect_ident_or_string()?;
806                        Ok(Statement::DropUser(name))
807                    }
808                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
809                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
810                        self.advance();
811                        let if_exists = self.consume_if_exists();
812                        let name = self.expect_ident_like()?;
813                        // ON <table>
814                        if !matches!(self.peek(), Token::On) {
815                            return Err(self.err(alloc::format!(
816                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
817                                self.peek()
818                            )));
819                        }
820                        self.advance();
821                        let table = self.expect_ident_like()?;
822                        Ok(Statement::DropTrigger {
823                            name,
824                            table,
825                            if_exists,
826                        })
827                    }
828                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
829                    // v7.12.4 ignores any optional arg-list (signature-
830                    // based overload disambiguation lands in v7.12.5+).
831                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
832                        self.advance();
833                        let if_exists = self.consume_if_exists();
834                        let name = self.expect_ident_like()?;
835                        // Optional `()` — consume + discard.
836                        if matches!(self.peek(), Token::LParen) {
837                            self.advance();
838                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
839                            let mut depth = 1usize;
840                            while depth > 0 {
841                                match self.peek() {
842                                    Token::LParen => depth += 1,
843                                    Token::RParen => depth -= 1,
844                                    Token::Eof => {
845                                        return Err(self.err(alloc::format!(
846                                            "unterminated arg list in DROP FUNCTION {name:?}"
847                                        )));
848                                    }
849                                    _ => {}
850                                }
851                                self.advance();
852                            }
853                        }
854                        Ok(Statement::DropFunction { name, if_exists })
855                    }
856                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
857                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
858                    // emit DROP TABLE IF EXISTS at the head of every
859                    // CREATE TABLE block so re-importing a dump
860                    // overwrites prior state. SPG accepts and removes
861                    // matching tables; CASCADE/RESTRICT trailers
862                    // accepted silently.
863                    Token::Table => {
864                        self.advance();
865                        let if_exists = self.consume_if_exists();
866                        let mut names: Vec<String> = Vec::new();
867                        loop {
868                            names.push(self.expect_ident_like()?);
869                            if matches!(self.peek(), Token::Comma) {
870                                self.advance();
871                                continue;
872                            }
873                            break;
874                        }
875                        if matches!(
876                            self.peek(),
877                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
878                                || s.eq_ignore_ascii_case("restrict")
879                        ) {
880                            self.advance();
881                        }
882                        Ok(Statement::DropTable { names, if_exists })
883                    }
884                    // v7.14.0 — DROP INDEX [IF EXISTS] name
885                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
886                    // for partial-index renames and pgvector
887                    // migrations. SPG removes the matching index;
888                    // IF EXISTS makes the drop idempotent.
889                    Token::Index => {
890                        self.advance();
891                        let if_exists = self.consume_if_exists();
892                        let name = self.expect_ident_like()?;
893                        if matches!(
894                            self.peek(),
895                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
896                                || s.eq_ignore_ascii_case("restrict")
897                        ) {
898                            self.advance();
899                        }
900                        Ok(Statement::DropIndex { name, if_exists })
901                    }
902                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
903                    // [CASCADE|RESTRICT]. SPG is single-database;
904                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
905                    // name [, name…] [CASCADE | RESTRICT]. Real
906                    // unregister (was silent no-op pre-v7.17).
907                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
908                        self.advance();
909                        let if_exists = self.consume_if_exists();
910                        let mut names = vec![self.expect_ident_like()?];
911                        while matches!(self.peek(), Token::Comma) {
912                            self.advance();
913                            names.push(self.expect_ident_like()?);
914                        }
915                        if matches!(
916                            self.peek(),
917                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
918                                || s.eq_ignore_ascii_case("restrict")
919                        ) {
920                            self.advance();
921                        }
922                        Ok(Statement::DropSchema { names, if_exists })
923                    }
924                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
925                    // name [, name…] [CASCADE|RESTRICT].
926                    Token::Ident(s) | Token::QuotedIdent(s)
927                        if s.eq_ignore_ascii_case("type") =>
928                    {
929                        self.advance();
930                        let if_exists = self.consume_if_exists();
931                        let mut names = vec![self.expect_ident_like()?];
932                        while matches!(self.peek(), Token::Comma) {
933                            self.advance();
934                            names.push(self.expect_ident_like()?);
935                        }
936                        if matches!(
937                            self.peek(),
938                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
939                                || s.eq_ignore_ascii_case("restrict")
940                        ) {
941                            self.advance();
942                        }
943                        Ok(Statement::DropType { names, if_exists })
944                    }
945                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
946                    // name [, name…] [CASCADE|RESTRICT].
947                    Token::Ident(s) | Token::QuotedIdent(s)
948                        if s.eq_ignore_ascii_case("domain") =>
949                    {
950                        self.advance();
951                        let if_exists = self.consume_if_exists();
952                        let mut names = vec![self.expect_ident_like()?];
953                        while matches!(self.peek(), Token::Comma) {
954                            self.advance();
955                            names.push(self.expect_ident_like()?);
956                        }
957                        if matches!(
958                            self.peek(),
959                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
960                                || s.eq_ignore_ascii_case("restrict")
961                        ) {
962                            self.advance();
963                        }
964                        Ok(Statement::DropDomain { names, if_exists })
965                    }
966                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
967                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
968                    Token::Ident(s) | Token::QuotedIdent(s)
969                        if s.eq_ignore_ascii_case("materialized") =>
970                    {
971                        self.advance();
972                        let nxt = self.peek().clone();
973                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
974                        {
975                            return Err(self.err(alloc::format!(
976                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
977                            )));
978                        }
979                        self.advance();
980                        let if_exists = self.consume_if_exists();
981                        let mut names = vec![self.expect_ident_like()?];
982                        while matches!(self.peek(), Token::Comma) {
983                            self.advance();
984                            names.push(self.expect_ident_like()?);
985                        }
986                        if matches!(
987                            self.peek(),
988                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
989                                || s.eq_ignore_ascii_case("restrict")
990                        ) {
991                            self.advance();
992                        }
993                        Ok(Statement::DropMaterializedView { names, if_exists })
994                    }
995                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
996                    // name [, name…] [CASCADE|RESTRICT].
997                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
998                        self.advance();
999                        let if_exists = self.consume_if_exists();
1000                        let mut names = vec![self.expect_ident_like()?];
1001                        while matches!(self.peek(), Token::Comma) {
1002                            self.advance();
1003                            names.push(self.expect_ident_like()?);
1004                        }
1005                        if matches!(
1006                            self.peek(),
1007                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
1008                                || s.eq_ignore_ascii_case("restrict")
1009                        ) {
1010                            self.advance();
1011                        }
1012                        Ok(Statement::DropView { names, if_exists })
1013                    }
1014                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
1015                    // [CASCADE|RESTRICT]. Real removal from catalog
1016                    // (was a silent no-op pre-v7.17).
1017                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1018                        self.advance();
1019                        let if_exists = self.consume_if_exists();
1020                        let mut names = vec![self.expect_ident_like()?];
1021                        while matches!(self.peek(), Token::Comma) {
1022                            self.advance();
1023                            names.push(self.expect_ident_like()?);
1024                        }
1025                        if matches!(
1026                            self.peek(),
1027                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
1028                                || s.eq_ignore_ascii_case("restrict")
1029                        ) {
1030                            self.advance();
1031                        }
1032                        Ok(Statement::DropSequence { names, if_exists })
1033                    }
1034                    other => Err(self.err(format!(
1035                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
1036                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
1037                    ))),
1038                }
1039            }
1040            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
1041            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
1042                self.advance();
1043                let nxt = self.peek().clone();
1044                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
1045                {
1046                    return Err(self.err(alloc::format!(
1047                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
1048                    )));
1049                }
1050                self.advance();
1051                let nxt2 = self.peek().clone();
1052                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1053                {
1054                    return Err(self.err(alloc::format!(
1055                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
1056                    )));
1057                }
1058                self.advance();
1059                let name = self.expect_ident_like()?;
1060                let with_data = self.parse_optional_with_data(true)?;
1061                Ok(Statement::RefreshMaterializedView { name, with_data })
1062            }
1063            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
1064                self.advance();
1065                self.parse_update_after_keyword()
1066            }
1067            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
1068                self.advance();
1069                self.parse_delete_after_keyword()
1070            }
1071            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
1072            // ALTER is not a reserved keyword in the lexer — handled
1073            // as a bare ident here.
1074            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
1075                self.advance();
1076                self.parse_alter_after_keyword()
1077            }
1078            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
1079            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
1080            // additions needed.
1081            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
1082                self.advance();
1083                self.parse_wait_after_keyword()
1084            }
1085            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
1086            // Bare ANALYZE → analyse every user table; ANALYZE
1087            // <name> → re-stats one. The argument is an optional
1088            // ident (or quoted ident); anything else is a parse
1089            // error.
1090            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
1091            // `WHERE` filter (carved out per V6_7_DESIGN.md
1092            // STABILITY). Lex order: identifier "compact" → "cold"
1093            // → "segments". Anything else after `COMPACT` is a
1094            // parse error.
1095            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
1096                self.advance();
1097                let next = self.peek().clone();
1098                let cold = match next {
1099                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1100                    _ => {
1101                        return Err(
1102                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
1103                        );
1104                    }
1105                };
1106                if !cold.eq_ignore_ascii_case("cold") {
1107                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
1108                }
1109                self.advance();
1110                let next = self.peek().clone();
1111                let segments = match next {
1112                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1113                    _ => {
1114                        return Err(self.err(format!(
1115                            "expected SEGMENTS after COMPACT COLD, got {:?}",
1116                            self.peek()
1117                        )));
1118                    }
1119                };
1120                if !segments.eq_ignore_ascii_case("segments") {
1121                    return Err(self.err(format!(
1122                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
1123                    )));
1124                }
1125                self.advance();
1126                Ok(Statement::CompactColdSegments)
1127            }
1128            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
1129            // Parsed as a case-insensitive identifier since MERGE
1130            // isn't a reserved lexer keyword (collides with the
1131            // mysqldump `ALGORITHM = MERGE` view clause if it
1132            // were); the inner parser drives the rest of the
1133            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
1134            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
1135                self.advance();
1136                self.parse_merge_after_keyword()
1137            }
1138            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
1139                self.advance();
1140                let target = match self.peek() {
1141                    Token::Eof | Token::Semicolon => None,
1142                    Token::Ident(_) | Token::QuotedIdent(_) => {
1143                        Some(self.expect_ident_like()?)
1144                    }
1145                    other => {
1146                        return Err(self.err(format!(
1147                            "expected table name or end of statement after ANALYZE, got {other:?}"
1148                        )));
1149                    }
1150                };
1151                Ok(Statement::Analyze(target))
1152            }
1153            // v7.12.1 — `SET <name> [TO|=] <value>`. The
1154            // `default_text_search_config` parameter is consumed
1155            // by the FTS function dispatcher; other parameter
1156            // names are recorded but treated as a no-op so PG
1157            // dump output loads.
1158            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
1159                self.advance();
1160                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
1161                // — accept and ignore. MySQL adds `SET GLOBAL` too
1162                // (and the alias `SET @@global.name = …` which the
1163                // SessionVar path handles).
1164                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"))
1165                {
1166                    self.advance();
1167                }
1168                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
1169                // <collation>]` — change the connection client
1170                // charset. SPG stores UTF-8 always and orders
1171                // bytewise; accept as a no-op.
1172                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
1173                {
1174                    self.advance();
1175                    // Charset ident-or-string.
1176                    if matches!(
1177                        self.peek(),
1178                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1179                    ) {
1180                        self.advance();
1181                    }
1182                    // Optional `COLLATE <name>`.
1183                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
1184                    {
1185                        self.advance();
1186                        if matches!(
1187                            self.peek(),
1188                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1189                        ) {
1190                            self.advance();
1191                        }
1192                    }
1193                    return Ok(Statement::Empty);
1194                }
1195                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
1196                // { DEFAULT | '<role>' | <ident> }` (mailrs
1197                // round-10 A.1). pg_dump preamble emits the
1198                // `DEFAULT` form to reset session authorization;
1199                // SPG has no role system so this is a strict
1200                // no-op. PG also accepts `RESET SESSION
1201                // AUTHORIZATION` (handled by the RESET parser
1202                // elsewhere). Reference:
1203                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
1204                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
1205                {
1206                    self.advance(); // AUTHORIZATION
1207                    match self.peek().clone() {
1208                        Token::Default => {
1209                            self.advance();
1210                        }
1211                        Token::String(_)
1212                        | Token::Ident(_)
1213                        | Token::QuotedIdent(_) => {
1214                            self.advance();
1215                        }
1216                        other => {
1217                            return Err(self.err(alloc::format!(
1218                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
1219                            )));
1220                        }
1221                    }
1222                    return Ok(Statement::Empty);
1223                }
1224                // v7.38 轴 4 — `SET [SESSION] TRANSACTION
1225                // ISOLATION LEVEL { READ COMMITTED | READ
1226                // UNCOMMITTED | REPEATABLE READ | SERIALIZABLE }
1227                // [, READ {ONLY|WRITE}] [, [NOT] DEFERRABLE]`.
1228                // PG-standard surface. v7.37.8 accepts the syntax
1229                // and tracks the selected level on
1230                // `Engine::current_isolation_level()`; the actual
1231                // MVCC / SSI semantics implementation lands in
1232                // the 轴 4 isolation framework (separate train).
1233                // PG itself maps READ UNCOMMITTED to READ COMMITTED
1234                // internally; SPG behaves the same (effectively
1235                // READ COMMITTED at every level today).
1236                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("transaction"))
1237                {
1238                    self.advance(); // TRANSACTION
1239                    let level = self.parse_isolation_level_clauses()?;
1240                    return Ok(Statement::SetTransaction { isolation: level });
1241                }
1242                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
1243                // alias — same accept-as-no-op as SET NAMES.
1244                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
1245                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
1246                {
1247                    self.advance(); // CHARACTER
1248                    self.advance(); // SET
1249                    if matches!(
1250                        self.peek(),
1251                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1252                    ) {
1253                        self.advance();
1254                    }
1255                    return Ok(Statement::Empty);
1256                }
1257                // v7.14.0 — multi-assignment form
1258                // `SET a = 1, b = 2, …`. Single-assignment is the
1259                // 1-element case. Each LHS may be a regular ident
1260                // or a SessionVar (`@VAR` / `@@VAR`).
1261                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
1262                loop {
1263                    let lhs = match self.peek().clone() {
1264                        Token::SessionVar(s) => {
1265                            self.advance();
1266                            s
1267                        }
1268                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
1269                        other => {
1270                            return Err(self.err(format!(
1271                                "expected parameter name after SET, got {other:?}"
1272                            )));
1273                        }
1274                    };
1275                    // Accept either `=` or the bare `TO` keyword.
1276                    match self.peek() {
1277                        Token::Eq => {
1278                            self.advance();
1279                        }
1280                        Token::To => {
1281                            self.advance();
1282                        }
1283                        other => {
1284                            return Err(self.err(format!(
1285                                "expected `=` or TO after SET {lhs}, got {other:?}"
1286                            )));
1287                        }
1288                    }
1289                    let value = self.parse_set_value()?;
1290                    pairs.push((lhs, value));
1291                    if matches!(self.peek(), Token::Comma) {
1292                        self.advance();
1293                        continue;
1294                    }
1295                    break;
1296                }
1297                if pairs.len() == 1 {
1298                    let (name, value) = pairs.into_iter().next().unwrap();
1299                    Ok(Statement::SetParameter { name, value })
1300                } else {
1301                    Ok(Statement::SetParameterList(pairs))
1302                }
1303            }
1304            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1305            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1306                self.advance();
1307                match self.peek().clone() {
1308                    Token::All => {
1309                        self.advance();
1310                        Ok(Statement::ResetParameter(None))
1311                    }
1312                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1313                        self.advance();
1314                        Ok(Statement::ResetParameter(None))
1315                    }
1316                    _ => {
1317                        let name = self.parse_set_param_name()?;
1318                        Ok(Statement::ResetParameter(Some(name)))
1319                    }
1320                }
1321            }
1322            other => Err(self.err(format!(
1323                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1324                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1325            ))),
1326        }
1327    }
1328
1329    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1330        debug_assert!(matches!(self.peek(), Token::Create));
1331        self.advance();
1332        match self.peek() {
1333            Token::Table => self.parse_create_table_stmt_after_create(),
1334            Token::Index => self.parse_create_index_stmt_after_create(false),
1335            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1336            // The `UNIQUE` modifier turns a partial index into a
1337            // partial-uniqueness invariant (only rows matching the
1338            // WHERE predicate are checked for duplicates). mailrs
1339            // K1 (3 hits: email_templates default, calendar_events
1340            // master, calendar_events instance).
1341            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1342                self.advance();
1343                if !matches!(self.peek(), Token::Index) {
1344                    return Err(self.err(alloc::format!(
1345                        "expected INDEX after CREATE UNIQUE, got {:?}",
1346                        self.peek()
1347                    )));
1348                }
1349                self.parse_create_index_stmt_after_create(true)
1350            }
1351            Token::Publication => {
1352                self.advance();
1353                self.parse_create_publication_after_keyword()
1354            }
1355            Token::Subscription => {
1356                self.advance();
1357                self.parse_create_subscription_after_keyword()
1358            }
1359            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1360            // USER isn't a reserved keyword — we look for the bare
1361            // identifier so the lexer doesn't have to grow a token.
1362            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1363                self.advance();
1364                self.parse_create_user_after_keyword()
1365            }
1366            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1367            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1368            // no-op. mailrs follow-up F3.
1369            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1370                self.advance();
1371                self.parse_create_extension_after_keyword()
1372            }
1373            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1374            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1375            // optional; absorb it here and forward to the
1376            // per-kind parsers with the flag. OR is a reserved
1377            // keyword token.
1378            Token::Or => {
1379                self.advance();
1380                let next = self.peek();
1381                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1382                    return Err(self.err(alloc::format!(
1383                        "expected REPLACE after CREATE OR, got {next:?}"
1384                    )));
1385                };
1386                if !s2.eq_ignore_ascii_case("replace") {
1387                    return Err(self.err(alloc::format!(
1388                        "expected REPLACE after CREATE OR, got {s2:?}"
1389                    )));
1390                }
1391                self.advance();
1392                self.parse_create_function_or_trigger_after_or_replace(true)
1393            }
1394            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1395                self.advance();
1396                self.parse_create_function_after_keyword(false)
1397            }
1398            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1399                self.advance();
1400                self.parse_create_trigger_after_keyword(false)
1401            }
1402            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1403            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1404                self.advance();
1405                self.parse_create_sequence_after_keyword(false)
1406            }
1407            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1408            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1409                self.advance();
1410                self.parse_create_view_after_keyword(false, false, false)
1411            }
1412            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1413            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1414            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1415            // appear (in any order) between `CREATE` and `VIEW` in
1416            // every mysqldump-emitted view. Pre-2.6 the parser
1417            // rejected the prefix and the customer's whole view
1418            // backup failed on the first view. The hints are pure
1419            // planner / permission metadata; SPG's view-rewrite
1420            // path is semantically equivalent for all three
1421            // algorithms in v7.17 (TEMPTABLE differs only in
1422            // perf for huge views — out of v7.17 scope), and
1423            // DEFINER / SQL SECURITY are pure single-user
1424            // permissioning that SPG ignores by design.
1425            Token::Ident(s) | Token::QuotedIdent(s)
1426                if s.eq_ignore_ascii_case("algorithm")
1427                    || s.eq_ignore_ascii_case("definer")
1428                    || s.eq_ignore_ascii_case("sql") =>
1429            {
1430                self.consume_mysql_view_prefix()?;
1431                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1432                // (in any order, in any combination), the next
1433                // keyword must be VIEW. mysqldump never emits these
1434                // prefixes on non-view statements.
1435                let next = self.peek().clone();
1436                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1437                    if s2.eq_ignore_ascii_case("view"))
1438                {
1439                    self.advance();
1440                    self.parse_create_view_after_keyword(false, false, false)
1441                } else {
1442                    Err(self.err(alloc::format!(
1443                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1444                    )))
1445                }
1446            }
1447            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1448            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1449                self.advance();
1450                self.parse_create_type_after_keyword()
1451            }
1452            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1453            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1454            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1455                self.advance();
1456                self.parse_create_domain_after_keyword()
1457            }
1458            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1459            // name [AUTHORIZATION user]. Real catalog registry
1460            // (was silent-no-op'd pre-v7.17).
1461            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1462                self.advance();
1463                let if_not_exists = self.parse_if_not_exists();
1464                let name = self.expect_ident_like()?;
1465                // Optional `AUTHORIZATION <user>` trailer — accepted,
1466                // ignored (single-user catalog).
1467                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1468                    if s.eq_ignore_ascii_case("authorization"))
1469                {
1470                    self.advance();
1471                    let _ = self.expect_ident_like()?;
1472                }
1473                Ok(Statement::CreateSchema { name, if_not_exists })
1474            }
1475            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1476            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1477                self.advance();
1478                let next = self.peek().clone();
1479                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1480                {
1481                    self.advance();
1482                    self.parse_create_materialized_view_after_keyword()
1483                } else {
1484                    Err(self.err(alloc::format!(
1485                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1486                    )))
1487                }
1488            }
1489            Token::Ident(s) | Token::QuotedIdent(s)
1490                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1491            {
1492                self.advance();
1493                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1494                let next = self.peek().clone();
1495                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1496                {
1497                    self.advance();
1498                    self.parse_create_sequence_after_keyword(true)
1499                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1500                {
1501                    self.advance();
1502                    self.parse_create_view_after_keyword(false, false, true)
1503                } else {
1504                    // TEMP TABLE etc — consume to boundary as noop for now.
1505                    self.consume_until_statement_boundary();
1506                    Ok(Statement::Empty)
1507                }
1508            }
1509            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1510            // BEGIN <body> END`. The body may reference `@var`
1511            // session variables, SET statements, internal `;`
1512            // terminators, etc. SPG has no procedure runtime, so
1513            // consume the whole `CREATE PROCEDURE … END` block as
1514            // a no-op so mysqldump scripts that include stored
1515            // routines load through. The matching-END consumer
1516            // tracks BEGIN/END nesting depth to handle nested
1517            // BEGIN blocks correctly.
1518            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1519                self.consume_mysql_routine_body();
1520                Ok(Statement::Empty)
1521            }
1522            // v7.14.0 — pg_dump / mysqldump emit
1523            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1524            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1525            // SPG is single-schema / single-database; these have
1526            // no behavioural effect, so consume + return Empty.
1527            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1528            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1529            // moved up to real parser branches. DATABASE / ROLE /
1530            // POLICY / OPERATOR stay no-op forever
1531            // (single-database, hardcoded roles).
1532            Token::Ident(s) | Token::QuotedIdent(s)
1533                if matches!(
1534                    s.to_ascii_lowercase().as_str(),
1535                    "database"
1536                        | "role"
1537                        | "policy"
1538                        | "operator"
1539                        | "cast"
1540                        | "rule"
1541                        | "aggregate"
1542                        | "language"
1543                        | "collation"
1544                        | "conversion"
1545                        // v7.17.0 Phase 8 (audit N6) — rarely-
1546                        // emitted pg_dump shapes that should
1547                        // load through without a parser error.
1548                        // SPG has no planner statistics catalog,
1549                        // no event-trigger hooks, no foreign-
1550                        // data-wrapper infrastructure; consume
1551                        // + return Empty.
1552                        | "statistics"
1553                        | "event"
1554                        | "foreign"
1555                ) =>
1556            {
1557                self.consume_until_statement_boundary();
1558                Ok(Statement::Empty)
1559            }
1560            other => Err(self.err(format!(
1561                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1562            ))),
1563        }
1564    }
1565
1566    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1567    /// keyword decides whether we parse a function or trigger
1568    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1569    /// PROCEDURE) — those land in later releases.
1570    fn parse_create_function_or_trigger_after_or_replace(
1571        &mut self,
1572        or_replace: bool,
1573    ) -> Result<Statement, ParseError> {
1574        let tok = self.peek();
1575        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1576            return Err(self.err(alloc::format!(
1577                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1578            )));
1579        };
1580        if s.eq_ignore_ascii_case("function") {
1581            self.advance();
1582            self.parse_create_function_after_keyword(or_replace)
1583        } else if s.eq_ignore_ascii_case("trigger") {
1584            self.advance();
1585            self.parse_create_trigger_after_keyword(or_replace)
1586        } else if s.eq_ignore_ascii_case("view") {
1587            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1588            self.advance();
1589            self.parse_create_view_after_keyword(or_replace, false, false)
1590        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1591            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1592            self.advance();
1593            let nxt = self.peek().clone();
1594            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1595            {
1596                self.advance();
1597                self.parse_create_view_after_keyword(or_replace, false, true)
1598            } else {
1599                Err(self.err(alloc::format!(
1600                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1601                )))
1602            }
1603        } else {
1604            Err(self.err(alloc::format!(
1605                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1606            )))
1607        }
1608    }
1609
1610    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1611    /// SPG doesn't have a registry; pgvector / similar are
1612    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1613    /// the syntax lets dual-target schemas keep the line.
1614    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1615        // Optional `IF NOT EXISTS`.
1616        self.consume_if_not_exists();
1617        let name = self.expect_ident_like()?;
1618        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1619        // CASCADE / FROM '<v>' clauses; we don't model them.
1620        loop {
1621            match self.peek() {
1622                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1623                    self.advance();
1624                    continue;
1625                }
1626                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1627                    self.advance();
1628                    let _ = self.expect_ident_like()?;
1629                    continue;
1630                }
1631                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1632                    self.advance();
1633                    // String or ident literal.
1634                    let _ = self.advance();
1635                    continue;
1636                }
1637                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1638                    self.advance();
1639                    let _ = self.advance();
1640                    continue;
1641                }
1642                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1643                    self.advance();
1644                    continue;
1645                }
1646                _ => break,
1647            }
1648        }
1649        Ok(Statement::CreateExtension(name))
1650    }
1651
1652    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1653    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1654    /// already been consumed by the caller. Grammar accepted:
1655    ///
1656    ///   name `(` arg-list `)`
1657    ///   `RETURNS` return-type
1658    ///   [ `LANGUAGE` ident ]
1659    ///   `AS` $$ body $$
1660    ///   [ `LANGUAGE` ident ]
1661    ///
1662    /// Either `LANGUAGE` position is allowed; PG accepts both.
1663    fn parse_create_function_after_keyword(
1664        &mut self,
1665        or_replace: bool,
1666    ) -> Result<Statement, ParseError> {
1667        let name = self.expect_ident_like()?;
1668        // Argument list. v7.12.4 commonly sees the empty `()`
1669        // (trigger functions); typed args parse and round-trip
1670        // but the executor only invokes nullary functions.
1671        if !matches!(self.peek(), Token::LParen) {
1672            return Err(self.err(alloc::format!(
1673                "expected '(' after function name {name:?}, got {:?}",
1674                self.peek()
1675            )));
1676        }
1677        self.advance();
1678        let args = self.parse_function_arg_list()?;
1679        // RETURNS clause.
1680        let tok = self.peek();
1681        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1682            return Err(self.err(alloc::format!(
1683                "expected RETURNS after function arg list, got {tok:?}"
1684            )));
1685        };
1686        if !s.eq_ignore_ascii_case("returns") {
1687            return Err(self.err(alloc::format!(
1688                "expected RETURNS after function arg list, got {s:?}"
1689            )));
1690        }
1691        self.advance();
1692        let returns = self.parse_function_return()?;
1693        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1694        // re-check after the body too).
1695        let mut language: Option<String> = self.parse_optional_language()?;
1696        // `AS` followed by a $$-quoted body (lexer already
1697        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1698        // Token::String). AS is a reserved keyword (Token::As).
1699        if !matches!(self.peek(), Token::As) {
1700            return Err(self.err(alloc::format!(
1701                "expected AS before function body, got {:?}",
1702                self.peek()
1703            )));
1704        }
1705        self.advance();
1706        let body_text = match self.peek() {
1707            Token::String(s) => {
1708                let body = s.clone();
1709                self.advance();
1710                body
1711            }
1712            other => {
1713                return Err(self.err(alloc::format!(
1714                    "expected $$-quoted function body after AS, got {other:?}"
1715                )));
1716            }
1717        };
1718        // Trailing optional LANGUAGE clause (the other PG position).
1719        if language.is_none() {
1720            language = self.parse_optional_language()?;
1721        }
1722        let language = language.unwrap_or_else(|| String::from("sql"));
1723        // PL/pgSQL bodies get structure-parsed. Other languages
1724        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1725        // recognise) round-trip as Raw text — the executor errors
1726        // when invoked with a clear unsupported message.
1727        let body = if language.eq_ignore_ascii_case("plpgsql") {
1728            match parse_plpgsql_body(&body_text) {
1729                Ok(block) => FunctionBody::PlPgSql(block),
1730                // Best-effort: if the body parser doesn't yet
1731                // support a construct used inside, fall back to
1732                // raw — keeps `CREATE FUNCTION` itself working
1733                // (catalogue accepts), executor errors on
1734                // invocation only.
1735                Err(_) => FunctionBody::Raw(body_text),
1736            }
1737        } else {
1738            FunctionBody::Raw(body_text)
1739        };
1740        Ok(Statement::CreateFunction(CreateFunctionStatement {
1741            name,
1742            or_replace,
1743            args,
1744            returns,
1745            language,
1746            body,
1747        }))
1748    }
1749
1750    /// Closing `)`-terminated argument list. v7.12.4 commonly
1751    /// sees the empty `()`; typed args round-trip but the
1752    /// executor (yet) doesn't invoke them.
1753    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1754        let mut args: Vec<FunctionArg> = Vec::new();
1755        if matches!(self.peek(), Token::RParen) {
1756            self.advance();
1757            return Ok(args);
1758        }
1759        loop {
1760            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1761            // a reserved token; OUT / INOUT are bare idents.
1762            let mode = if matches!(self.peek(), Token::In) {
1763                self.advance();
1764                FunctionArgMode::In
1765            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1766            {
1767                self.advance();
1768                FunctionArgMode::Out
1769            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1770            {
1771                self.advance();
1772                FunctionArgMode::InOut
1773            } else {
1774                FunctionArgMode::In
1775            };
1776            // Optional name. The next token is either a name
1777            // (followed by a type ident) or the type itself.
1778            // Disambiguate by peeking ahead: if the token after
1779            // the next ident is also an ident, we treat the
1780            // first as the name.
1781            let (name, ty_token) = {
1782                let first = self.expect_ident_like()?;
1783                // Peek next: if it's an ident (i.e. a type
1784                // name) the `first` was the arg name.
1785                match self.peek() {
1786                    Token::Ident(_) | Token::QuotedIdent(_) => {
1787                        let ty = self.expect_ident_like()?;
1788                        (Some(first), ty)
1789                    }
1790                    _ => (None, first),
1791                }
1792            };
1793            // Type — try to map to ColumnTypeName, else Raw.
1794            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1795                Some(t) => FunctionArgType::Typed(t),
1796                None => FunctionArgType::Raw(ty_token),
1797            };
1798            args.push(FunctionArg { mode, name, ty });
1799            match self.peek() {
1800                Token::Comma => {
1801                    self.advance();
1802                    continue;
1803                }
1804                Token::RParen => {
1805                    self.advance();
1806                    return Ok(args);
1807                }
1808                other => {
1809                    return Err(self.err(alloc::format!(
1810                        "expected , or ) in function arg list, got {other:?}"
1811                    )));
1812                }
1813            }
1814        }
1815    }
1816
1817    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1818        let ident = self.expect_ident_like()?;
1819        if ident.eq_ignore_ascii_case("trigger") {
1820            return Ok(FunctionReturn::Trigger);
1821        }
1822        if ident.eq_ignore_ascii_case("void") {
1823            return Ok(FunctionReturn::Void);
1824        }
1825        match map_type_ident_to_column_type_name(&ident) {
1826            Some(t) => Ok(FunctionReturn::Type(t)),
1827            None => Ok(FunctionReturn::Other(ident)),
1828        }
1829    }
1830
1831    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1832        match self.peek() {
1833            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1834                self.advance();
1835                let lang = self.expect_ident_like()?;
1836                Ok(Some(lang.to_ascii_lowercase()))
1837            }
1838            _ => Ok(None),
1839        }
1840    }
1841
1842    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1843    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1844    /// (expr)]*`. The `DOMAIN` keyword has already been
1845    /// consumed. PG allows the trailing constraints in any
1846    /// order; we approximate with a small loop.
1847    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1848        let name = self.expect_ident_like()?;
1849        // Optional `AS`.
1850        if matches!(self.peek(), Token::As) {
1851            self.advance();
1852        }
1853        let base_type = self.parse_column_type_name()?;
1854        let mut default: Option<Expr> = None;
1855        let mut not_null = false;
1856        let mut checks: Vec<Expr> = Vec::new();
1857        loop {
1858            match self.peek() {
1859                Token::Default => {
1860                    if default.is_some() {
1861                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1862                    }
1863                    self.advance();
1864                    default = Some(self.parse_expr(0)?);
1865                }
1866                Token::Not => {
1867                    self.advance();
1868                    if !matches!(self.peek(), Token::Null) {
1869                        return Err(self.err(alloc::format!(
1870                            "expected NULL after NOT in DOMAIN, got {:?}",
1871                            self.peek()
1872                        )));
1873                    }
1874                    self.advance();
1875                    not_null = true;
1876                }
1877                Token::Null => {
1878                    self.advance();
1879                    // NULL after a NOT NULL is contradictory, but
1880                    // PG accepts bare NULL as the default-nullable
1881                    // marker. No-op.
1882                }
1883                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1884                    self.advance();
1885                    if !matches!(self.peek(), Token::LParen) {
1886                        return Err(self.err(alloc::format!(
1887                            "expected '(' after CHECK in DOMAIN, got {:?}",
1888                            self.peek()
1889                        )));
1890                    }
1891                    self.advance();
1892                    let expr = self.parse_expr(0)?;
1893                    if !matches!(self.peek(), Token::RParen) {
1894                        return Err(self.err(alloc::format!(
1895                            "expected ')' after CHECK expr, got {:?}",
1896                            self.peek()
1897                        )));
1898                    }
1899                    self.advance();
1900                    checks.push(expr);
1901                }
1902                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1903                // prefix on the constraint; we drop the name and
1904                // recurse into the constraint parsing.
1905                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1906                    self.advance();
1907                    let _ = self.expect_ident_like()?;
1908                }
1909                _ => break,
1910            }
1911        }
1912        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1913            name,
1914            base_type,
1915            default,
1916            not_null,
1917            checks,
1918        }))
1919    }
1920
1921    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1922    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1923    /// consumed.
1924    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1925        let name = self.expect_ident_like()?;
1926        // Required `AS`.
1927        if !matches!(self.peek(), Token::As) {
1928            return Err(self.err(alloc::format!(
1929                "expected AS after CREATE TYPE {name:?}, got {:?}",
1930                self.peek()
1931            )));
1932        }
1933        self.advance();
1934        // v7.37.x (ζ-B composite Phase 1) — `AS (` is the composite-
1935        // type shape: `CREATE TYPE foo AS (a INT, b TEXT)`. Branch
1936        // on the next token: `(` = composite, ident `ENUM` = enum.
1937        if matches!(self.peek(), Token::LParen) {
1938            self.advance();
1939            let mut fields: Vec<(String, ColumnTypeName)> = Vec::new();
1940            loop {
1941                let field_name = self.expect_ident_like()?;
1942                let field_type = self.parse_column_type_name()?;
1943                fields.push((field_name, field_type));
1944                if matches!(self.peek(), Token::Comma) {
1945                    self.advance();
1946                    continue;
1947                }
1948                if matches!(self.peek(), Token::RParen) {
1949                    self.advance();
1950                    break;
1951                }
1952                return Err(self.err(alloc::format!(
1953                    "expected , or ) in composite field list, got {:?}",
1954                    self.peek()
1955                )));
1956            }
1957            if fields.is_empty() {
1958                return Err(self.err("CREATE TYPE … AS (…) must declare at least one field".into()));
1959            }
1960            return Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1961                name,
1962                kind: crate::ast::TypeKind::Composite { fields },
1963            }));
1964        }
1965        // Required `ENUM` ident.
1966        let kind_ident = match self.peek().clone() {
1967            Token::Ident(s) | Token::QuotedIdent(s) => s,
1968            other => {
1969                return Err(self.err(alloc::format!(
1970                    "expected ENUM or '(' after CREATE TYPE {name:?} AS, got {other:?}"
1971                )));
1972            }
1973        };
1974        if !kind_ident.eq_ignore_ascii_case("enum") {
1975            return Err(self.err(alloc::format!(
1976                "Phase 1.4 only supports ENUM or composite '(…)'; got {kind_ident:?}"
1977            )));
1978        }
1979        self.advance();
1980        if !matches!(self.peek(), Token::LParen) {
1981            return Err(self.err(alloc::format!(
1982                "expected '(' after ENUM, got {:?}",
1983                self.peek()
1984            )));
1985        }
1986        self.advance();
1987        let mut labels: Vec<String> = Vec::new();
1988        loop {
1989            match self.peek().clone() {
1990                Token::String(s) => {
1991                    self.advance();
1992                    labels.push(s);
1993                }
1994                other => {
1995                    return Err(
1996                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1997                    );
1998                }
1999            }
2000            if matches!(self.peek(), Token::Comma) {
2001                self.advance();
2002                continue;
2003            }
2004            if matches!(self.peek(), Token::RParen) {
2005                self.advance();
2006                break;
2007            }
2008            return Err(self.err(alloc::format!(
2009                "expected , or ) in ENUM label list, got {:?}",
2010                self.peek()
2011            )));
2012        }
2013        if labels.is_empty() {
2014            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
2015        }
2016        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
2017            name,
2018            kind: crate::ast::TypeKind::Enum { labels },
2019        }))
2020    }
2021
2022    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
2023    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
2024    /// The `CREATE MATERIALIZED VIEW` keywords have already been
2025    /// consumed.
2026    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
2027        let if_not_exists = self.parse_if_not_exists();
2028        let name = self.expect_ident_like()?;
2029        let mut columns: Vec<String> = Vec::new();
2030        if matches!(self.peek(), Token::LParen) {
2031            self.advance();
2032            loop {
2033                let c = self.expect_ident_like()?;
2034                columns.push(c);
2035                if matches!(self.peek(), Token::Comma) {
2036                    self.advance();
2037                    continue;
2038                }
2039                if matches!(self.peek(), Token::RParen) {
2040                    self.advance();
2041                    break;
2042                }
2043                return Err(self.err(alloc::format!(
2044                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
2045                    self.peek()
2046                )));
2047            }
2048        }
2049        if !matches!(self.peek(), Token::As) {
2050            return Err(self.err(alloc::format!(
2051                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
2052                self.peek()
2053            )));
2054        }
2055        self.advance();
2056        let body_stmt = self.parse_select_stmt()?;
2057        let Statement::Select(body) = body_stmt else {
2058            return Err(self.err(alloc::format!(
2059                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
2060            )));
2061        };
2062        // Optional trailing `WITH [NO] DATA`.
2063        let with_data = self.parse_optional_with_data(true)?;
2064        Ok(Statement::CreateMaterializedView(
2065            crate::ast::CreateMaterializedViewStatement {
2066                name,
2067                if_not_exists,
2068                columns,
2069                body,
2070                with_data,
2071            },
2072        ))
2073    }
2074
2075    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
2076    /// `default_when_absent` is what to return if the tail is
2077    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
2078    /// WITH DATA).
2079    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
2080        let save = self.pos;
2081        // `WITH` is an Ident (not reserved in the lexer).
2082        let is_with = match self.peek() {
2083            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
2084            _ => false,
2085        };
2086        if !is_with {
2087            return Ok(default_when_absent);
2088        }
2089        self.advance();
2090        // Optional `NO`.
2091        let mut with_data = true;
2092        let is_no = match self.peek() {
2093            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
2094            _ => false,
2095        };
2096        if is_no {
2097            self.advance();
2098            with_data = false;
2099        }
2100        // Required `DATA` ident.
2101        let is_data = match self.peek() {
2102            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
2103            _ => false,
2104        };
2105        if is_data {
2106            self.advance();
2107            Ok(with_data)
2108        } else {
2109            // Caller's WITH wasn't WITH-DATA — rewind so the outer
2110            // parser can interpret it.
2111            self.pos = save;
2112            Ok(default_when_absent)
2113        }
2114    }
2115
2116    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
2117    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
2118    /// All keyword prefixes have already been consumed; the flags
2119    /// say which were present.
2120    fn parse_create_view_after_keyword(
2121        &mut self,
2122        or_replace: bool,
2123        _materialized_unused: bool,
2124        temporary: bool,
2125    ) -> Result<Statement, ParseError> {
2126        let if_not_exists = self.parse_if_not_exists();
2127        let name = self.expect_ident_like()?;
2128        // Optional `(col, col, …)` rename list.
2129        let mut columns: Vec<String> = Vec::new();
2130        if matches!(self.peek(), Token::LParen) {
2131            self.advance();
2132            loop {
2133                let c = self.expect_ident_like()?;
2134                columns.push(c);
2135                if matches!(self.peek(), Token::Comma) {
2136                    self.advance();
2137                    continue;
2138                }
2139                if matches!(self.peek(), Token::RParen) {
2140                    self.advance();
2141                    break;
2142                }
2143                return Err(self.err(alloc::format!(
2144                    "expected , or ) in VIEW column list, got {:?}",
2145                    self.peek()
2146                )));
2147            }
2148        }
2149        // Required `AS`.
2150        if !matches!(self.peek(), Token::As) {
2151            return Err(self.err(alloc::format!(
2152                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
2153                self.peek()
2154            )));
2155        }
2156        self.advance();
2157        // Body: a regular SELECT statement.
2158        let body_stmt = self.parse_select_stmt()?;
2159        let Statement::Select(body) = body_stmt else {
2160            return Err(self.err(alloc::format!(
2161                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
2162            )));
2163        };
2164        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
2165            name,
2166            or_replace,
2167            if_not_exists,
2168            temporary,
2169            columns,
2170            body,
2171        }))
2172    }
2173
2174    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
2175    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
2176    /// consumed; `temporary` carries whether TEMPORARY was seen.
2177    fn parse_create_sequence_after_keyword(
2178        &mut self,
2179        temporary: bool,
2180    ) -> Result<Statement, ParseError> {
2181        let if_not_exists = self.parse_if_not_exists();
2182        let name = self.expect_ident_like()?;
2183        // Optional `AS data_type`.
2184        let data_type = if matches!(self.peek(), Token::As) {
2185            self.advance();
2186            Some(self.parse_sequence_data_type()?)
2187        } else {
2188            None
2189        };
2190        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
2191        Ok(Statement::CreateSequence(
2192            crate::ast::CreateSequenceStatement {
2193                name,
2194                if_not_exists,
2195                temporary,
2196                data_type,
2197                options,
2198            },
2199        ))
2200    }
2201
2202    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
2203    /// already been consumed; this is reached after `SEQUENCE`.
2204    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
2205        let if_exists = self.parse_if_exists();
2206        let name = self.expect_ident_like()?;
2207        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
2208        Ok(Statement::AlterSequence(
2209            crate::ast::AlterSequenceStatement {
2210                name,
2211                if_exists,
2212                options,
2213            },
2214        ))
2215    }
2216
2217    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
2218        let kw = self.expect_ident_like()?;
2219        match kw.to_ascii_lowercase().as_str() {
2220            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
2221            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
2222            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
2223            other => Err(self.err(alloc::format!(
2224                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
2225            ))),
2226        }
2227    }
2228
2229    fn parse_sequence_options(
2230        &mut self,
2231        allow_restart: bool,
2232    ) -> Result<crate::ast::SequenceOptions, ParseError> {
2233        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
2234        let mut opts = SequenceOptions::default();
2235        #[allow(clippy::while_let_loop)]
2236        loop {
2237            // Match an ident; stop at any non-ident token (sentinel,
2238            // semicolon, end of statement).
2239            let kw_lc = match self.peek() {
2240                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
2241                _ => break,
2242            };
2243            match kw_lc.as_str() {
2244                "increment" => {
2245                    self.advance();
2246                    // Optional BY.
2247                    if matches!(self.peek(), Token::By) {
2248                        self.advance();
2249                    }
2250                    opts.increment = Some(self.expect_signed_int()?);
2251                }
2252                "minvalue" => {
2253                    self.advance();
2254                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
2255                }
2256                "maxvalue" => {
2257                    self.advance();
2258                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
2259                }
2260                "no" => {
2261                    self.advance();
2262                    let what = self.expect_ident_like()?;
2263                    match what.to_ascii_lowercase().as_str() {
2264                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
2265                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
2266                        "cycle" => opts.cycle = Some(false),
2267                        other => {
2268                            return Err(self.err(alloc::format!(
2269                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
2270                            )));
2271                        }
2272                    }
2273                }
2274                "start" => {
2275                    self.advance();
2276                    // Optional WITH.
2277                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2278                        if s.eq_ignore_ascii_case("with"))
2279                    {
2280                        self.advance();
2281                    }
2282                    opts.start = Some(self.expect_signed_int()?);
2283                }
2284                "restart" if allow_restart => {
2285                    self.advance();
2286                    // Optional WITH n; bare RESTART means restart at START.
2287                    let mut with_val: Option<i64> = None;
2288                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2289                        if s.eq_ignore_ascii_case("with"))
2290                    {
2291                        self.advance();
2292                        with_val = Some(self.expect_signed_int()?);
2293                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
2294                        with_val = Some(self.expect_signed_int()?);
2295                    }
2296                    opts.restart = Some(with_val);
2297                }
2298                "cache" => {
2299                    self.advance();
2300                    opts.cache = Some(self.expect_signed_int()?);
2301                }
2302                "cycle" => {
2303                    self.advance();
2304                    opts.cycle = Some(true);
2305                }
2306                "owned" => {
2307                    self.advance();
2308                    // BY is a reserved Token::By; accept either form.
2309                    match self.peek() {
2310                        Token::By => {
2311                            self.advance();
2312                        }
2313                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
2314                            self.advance();
2315                        }
2316                        other => {
2317                            return Err(
2318                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
2319                            );
2320                        }
2321                    }
2322                    // OWNED BY {NONE | tab.col}. Read just one ident
2323                    // (NOT expect_ident_like which would auto-strip
2324                    // a schema prefix and consume the `.col` we need).
2325                    let first = match self.advance() {
2326                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2327                        other => {
2328                            return Err(self.err(alloc::format!(
2329                                "expected identifier or NONE after OWNED BY, got {other:?}"
2330                            )));
2331                        }
2332                    };
2333                    if first.eq_ignore_ascii_case("none") {
2334                        opts.owned_by = Some(SequenceOwnedBy::None);
2335                    } else if matches!(self.peek(), Token::Dot) {
2336                        self.advance();
2337                        let second = match self.advance() {
2338                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2339                            other => {
2340                                return Err(self.err(alloc::format!(
2341                                    "expected column name after OWNED BY {first}., got {other:?}"
2342                                )));
2343                            }
2344                        };
2345                        // v7.17 dump-compat fix — pg_dump emits
2346                        // OWNED BY clauses as
2347                        // `schema.table.column` (three segments).
2348                        // If a third `.<ident>` follows, treat the
2349                        // first ident as schema (drop it; SPG is
2350                        // single-schema) and the middle / last
2351                        // pair as table.column. Otherwise it's
2352                        // the two-segment form table.column.
2353                        if matches!(self.peek(), Token::Dot) {
2354                            self.advance();
2355                            let third = match self.advance() {
2356                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2357                                other => {
2358                                    return Err(self.err(alloc::format!(
2359                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2360                                    )));
2361                                }
2362                            };
2363                            let _ = first; // schema prefix discarded
2364                            opts.owned_by = Some(SequenceOwnedBy::Column {
2365                                table: second,
2366                                column: third,
2367                            });
2368                        } else {
2369                            opts.owned_by = Some(SequenceOwnedBy::Column {
2370                                table: first,
2371                                column: second,
2372                            });
2373                        }
2374                    } else {
2375                        return Err(self.err(alloc::format!(
2376                            "expected table.column or NONE after OWNED BY, got {first:?}"
2377                        )));
2378                    }
2379                }
2380                _ => break,
2381            }
2382        }
2383        Ok(opts)
2384    }
2385
2386    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2387        let neg = if matches!(self.peek(), Token::Minus) {
2388            self.advance();
2389            true
2390        } else {
2391            false
2392        };
2393        match self.peek() {
2394            Token::Integer(n) => {
2395                let v = *n;
2396                self.advance();
2397                Ok(if neg { -v } else { v })
2398            }
2399            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2400        }
2401    }
2402
2403    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2404    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2405    /// clause is fully accepted and discarded — SPG always runs
2406    /// constraint checks immediately (single-writer model). The
2407    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2408    /// in either order (per the SQL spec they're independent),
2409    /// though pg_dump always emits them in the canonical
2410    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2411    /// Stops at the first token that isn't part of the clause.
2412    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2413        loop {
2414            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2415            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2416                self.advance();
2417                self.consume_optional_initially_clause()?;
2418                continue;
2419            }
2420            // `NOT DEFERRABLE` — already worked pre-3.1.
2421            if matches!(self.peek(), Token::Not) {
2422                let look = self.tokens.get(self.pos + 1);
2423                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2424                    self.advance(); // NOT
2425                    self.advance(); // DEFERRABLE
2426                    self.consume_optional_initially_clause()?;
2427                    continue;
2428                }
2429                break;
2430            }
2431            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2432            // accepts this without a leading [NOT] DEFERRABLE
2433            // (the timing keyword alone). pg_dump occasionally
2434            // emits it on FK constraints that inherit timing.
2435            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2436                self.consume_optional_initially_clause()?;
2437                continue;
2438            }
2439            break;
2440        }
2441        Ok(())
2442    }
2443
2444    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2445    /// next token is `INITIALLY`, consume it plus the required
2446    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2447    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2448        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2449            return Ok(());
2450        }
2451        self.advance(); // INITIALLY
2452        match self.advance() {
2453            Token::Ident(s)
2454                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2455            {
2456                Ok(())
2457            }
2458            other => Err(self.err(alloc::format!(
2459                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2460            ))),
2461        }
2462    }
2463
2464    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2465    /// in its entirety so the parser returns Empty without
2466    /// touching the runtime. The CREATE+PROCEDURE keywords are
2467    /// already consumed; this swallows everything from the
2468    /// procedure name through the matching `END`, including
2469    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2470    /// (DELIMITER `//` makes the script splitter forward the
2471    /// whole block as one statement), `@var` session-variable
2472    /// references, and the trailing terminator.
2473    ///
2474    /// Tracks nesting depth so:
2475    ///   BEGIN
2476    ///     IF cond THEN
2477    ///       BEGIN ... END;
2478    ///     END IF;
2479    ///   END
2480    /// terminates at the outer END.
2481    fn consume_mysql_routine_body(&mut self) {
2482        // Outer skeleton: name, (...), optional clauses, BEGIN
2483        // <body> END [;]. Scan for the first BEGIN — anything
2484        // before it is signature decoration we don't care about.
2485        // Once inside BEGIN, count up on BEGIN, down on END.
2486        let mut depth: i32 = 0;
2487        let mut started = false;
2488        loop {
2489            match self.peek().clone() {
2490                Token::Begin => {
2491                    self.advance();
2492                    depth += 1;
2493                    started = true;
2494                }
2495                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2496                    self.advance();
2497                    if started {
2498                        depth -= 1;
2499                        if depth <= 0 {
2500                            // Optional trailing ident (`END IF`,
2501                            // `END LOOP`, `END WHILE`, `END CASE`,
2502                            // `END label_name`) — eat the next
2503                            // ident if present so we don't
2504                            // mistake `END IF;` for the outer
2505                            // close.
2506                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2507                                // If the next token is one of the
2508                                // PL/SQL block-closer keywords,
2509                                // the END belongs to an inner
2510                                // block; bump depth back up.
2511                                let is_inner_close = matches!(
2512                                    self.peek(),
2513                                    Token::Ident(s) | Token::QuotedIdent(s)
2514                                        if matches!(
2515                                            s.to_ascii_lowercase().as_str(),
2516                                            "if" | "loop" | "while" | "case" | "repeat"
2517                                        )
2518                                );
2519                                if is_inner_close {
2520                                    self.advance();
2521                                    depth += 1;
2522                                    continue;
2523                                }
2524                            }
2525                            // Eat optional trailing `;`.
2526                            if matches!(self.peek(), Token::Semicolon) {
2527                                self.advance();
2528                            }
2529                            return;
2530                        }
2531                    }
2532                }
2533                Token::Eof => return,
2534                _ => {
2535                    self.advance();
2536                }
2537            }
2538        }
2539    }
2540
2541    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2542    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2543    ///
2544    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2545    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2546    ///   ident, or `ident @ ident-or-quoted-string` host form)
2547    /// * `SQL SECURITY {DEFINER|INVOKER}`
2548    ///
2549    /// Each clause may appear at most once but in any order.
2550    /// The hints are pure planner / permission metadata that
2551    /// SPG's view-rewrite engine handles uniformly; we accept
2552    /// and discard. Returns `Ok(())` once a non-clause token is
2553    /// peeked (the caller then checks for the `VIEW` keyword).
2554    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2555        loop {
2556            match self.peek().clone() {
2557                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2558                    self.advance(); // ALGORITHM
2559                    // Optional `=`. MySQL spec requires it but be
2560                    // generous.
2561                    if matches!(self.peek(), Token::Eq) {
2562                        self.advance();
2563                    }
2564                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2565                    // bare ident; unknown values still parse so
2566                    // future MySQL versions don't break.
2567                    if matches!(
2568                        self.peek(),
2569                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2570                    ) {
2571                        self.advance();
2572                    }
2573                }
2574                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2575                    self.advance(); // DEFINER
2576                    if matches!(self.peek(), Token::Eq) {
2577                        self.advance();
2578                    }
2579                    // User: quoted string, ident, OR ident @ host
2580                    // (host may itself be quoted or bare).
2581                    match self.peek().clone() {
2582                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2583                            self.advance();
2584                            // Optional `@host`.
2585                            if matches!(self.peek(), Token::At) {
2586                                self.advance();
2587                                if matches!(
2588                                    self.peek(),
2589                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2590                                ) {
2591                                    self.advance();
2592                                }
2593                            }
2594                        }
2595                        _ => {}
2596                    }
2597                }
2598                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2599                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2600                    // when followed by SECURITY — the dispatcher must
2601                    // not consume a bare `SQL` token (it's not a
2602                    // legal CREATE prefix on its own).
2603                    let save = self.pos;
2604                    self.advance(); // SQL
2605                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2606                        if s2.eq_ignore_ascii_case("security"))
2607                    {
2608                        self.advance(); // SECURITY
2609                        // DEFINER / INVOKER trailing ident.
2610                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2611                            self.advance();
2612                        }
2613                    } else {
2614                        // Not a SQL SECURITY clause — roll back and
2615                        // bail; the caller will error out cleanly.
2616                        self.pos = save;
2617                        return Ok(());
2618                    }
2619                }
2620                _ => return Ok(()),
2621            }
2622        }
2623    }
2624
2625    fn parse_if_not_exists(&mut self) -> bool {
2626        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2627        {
2628            let save = self.pos;
2629            self.advance();
2630            if matches!(self.peek(), Token::Not) {
2631                self.advance();
2632                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2633                {
2634                    self.advance();
2635                    return true;
2636                }
2637            }
2638            self.pos = save;
2639        }
2640        false
2641    }
2642
2643    fn parse_if_exists(&mut self) -> bool {
2644        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2645        {
2646            let save = self.pos;
2647            self.advance();
2648            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2649            {
2650                self.advance();
2651                return true;
2652            }
2653            self.pos = save;
2654        }
2655        false
2656    }
2657
2658    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2659    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2660    /// been consumed.
2661    fn parse_create_trigger_after_keyword(
2662        &mut self,
2663        or_replace: bool,
2664    ) -> Result<Statement, ParseError> {
2665        let name = self.expect_ident_like()?;
2666        let timing = {
2667            let ident = self.expect_ident_like()?;
2668            if ident.eq_ignore_ascii_case("before") {
2669                TriggerTiming::Before
2670            } else if ident.eq_ignore_ascii_case("after") {
2671                TriggerTiming::After
2672            } else if ident.eq_ignore_ascii_case("instead") {
2673                let next = self.expect_ident_like()?;
2674                if !next.eq_ignore_ascii_case("of") {
2675                    return Err(self.err(alloc::format!(
2676                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2677                    )));
2678                }
2679                TriggerTiming::InsteadOf
2680            } else {
2681                return Err(self.err(alloc::format!(
2682                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2683                )));
2684            }
2685        };
2686        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2687        // OR is a reserved keyword token (Token::Or), not an Ident.
2688        // v7.13.0 — after an UPDATE event we may optionally see
2689        // `OF col, col, …` (mailrs round-5 G7). Columns are
2690        // captured into `update_columns` once across the whole
2691        // events list; multiple `UPDATE OF` clauses are rejected.
2692        let mut events: Vec<TriggerEvent> = Vec::new();
2693        let mut update_columns: Vec<String> = Vec::new();
2694        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2695        events.push(first_ev);
2696        if !first_cols.is_empty() {
2697            update_columns = first_cols;
2698        }
2699        while matches!(self.peek(), Token::Or) {
2700            self.advance();
2701            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2702            events.push(ev);
2703            if !cols.is_empty() {
2704                if !update_columns.is_empty() {
2705                    return Err(
2706                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2707                    );
2708                }
2709                update_columns = cols;
2710            }
2711        }
2712        // ON <table>
2713        let tok = self.peek();
2714        let Token::On = tok else {
2715            return Err(self.err(alloc::format!(
2716                "expected ON after trigger events, got {tok:?}"
2717            )));
2718        };
2719        self.advance();
2720        let table = self.expect_ident_like()?;
2721        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2722        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2723        // idents.
2724        if !matches!(self.peek(), Token::For) {
2725            return Err(self.err(alloc::format!(
2726                "expected FOR EACH ROW / STATEMENT, got {:?}",
2727                self.peek()
2728            )));
2729        }
2730        self.advance();
2731        let for_each = {
2732            let e = self.expect_ident_like()?;
2733            if !e.eq_ignore_ascii_case("each") {
2734                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2735            }
2736            let unit = self.expect_ident_like()?;
2737            if unit.eq_ignore_ascii_case("row") {
2738                TriggerForEach::Row
2739            } else if unit.eq_ignore_ascii_case("statement") {
2740                TriggerForEach::Statement
2741            } else {
2742                return Err(self.err(alloc::format!(
2743                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2744                )));
2745            }
2746        };
2747        // EXECUTE FUNCTION/PROCEDURE name(...)
2748        let exec = self.expect_ident_like()?;
2749        if !exec.eq_ignore_ascii_case("execute") {
2750            return Err(self.err(alloc::format!(
2751                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2752            )));
2753        }
2754        let fn_or_proc = self.expect_ident_like()?;
2755        if !(fn_or_proc.eq_ignore_ascii_case("function")
2756            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2757        {
2758            return Err(self.err(alloc::format!(
2759                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2760            )));
2761        }
2762        let function = self.expect_ident_like()?;
2763        // Optional empty arg list `()`.
2764        if matches!(self.peek(), Token::LParen) {
2765            self.advance();
2766            if !matches!(self.peek(), Token::RParen) {
2767                return Err(self.err(alloc::format!(
2768                    "v7.12.4 trigger function calls take no args; got {:?}",
2769                    self.peek()
2770                )));
2771            }
2772            self.advance();
2773        }
2774        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2775            name,
2776            or_replace,
2777            timing,
2778            events,
2779            table,
2780            for_each,
2781            function,
2782            update_columns,
2783        }))
2784    }
2785
2786    /// v7.13.0 — parse one trigger event, then optionally consume
2787    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2788    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2789    fn parse_trigger_event_with_optional_of(
2790        &mut self,
2791    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2792        let ev = self.parse_trigger_event()?;
2793        if !matches!(ev, TriggerEvent::Update) {
2794            return Ok((ev, Vec::new()));
2795        }
2796        // `OF` is a bare ident.
2797        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2798            return Ok((ev, Vec::new()));
2799        }
2800        self.advance(); // OF
2801        let mut cols: Vec<String> = Vec::new();
2802        loop {
2803            cols.push(self.expect_ident_like()?);
2804            if matches!(self.peek(), Token::Comma) {
2805                self.advance();
2806                continue;
2807            }
2808            break;
2809        }
2810        if cols.is_empty() {
2811            return Err(
2812                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2813            );
2814        }
2815        Ok((ev, cols))
2816    }
2817
2818    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2819    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2820    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2821    /// inside the body.
2822    /// Called by [`parse_plpgsql_body`] after the body's tokens
2823    /// have been lexed into this temporary parser.
2824    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2825        // v7.12.6 — optional DECLARE prelude.
2826        let declarations = if matches!(
2827            self.peek(),
2828            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2829        ) {
2830            self.advance();
2831            self.parse_plpgsql_declare_block()?
2832        } else {
2833            Vec::new()
2834        };
2835        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2836        // `BEGIN` transaction-start, but we can reuse the
2837        // reserved Token::Begin since the body is a separate
2838        // lex/parse context).
2839        if !matches!(self.peek(), Token::Begin) {
2840            return Err(self.err(alloc::format!(
2841                "expected BEGIN at start of plpgsql block, got {:?}",
2842                self.peek()
2843            )));
2844        }
2845        self.advance();
2846        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2847        Ok(PlPgSqlBlock {
2848            declarations,
2849            statements,
2850        })
2851    }
2852
2853    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2854    /// prelude. Caller has already consumed `DECLARE`. We stop
2855    /// reading entries when we hit `BEGIN`.
2856    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2857        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2858        loop {
2859            if matches!(self.peek(), Token::Begin) {
2860                return Ok(out);
2861            }
2862            let name = self.expect_ident_like()?;
2863            let ty_token = self.expect_ident_like()?;
2864            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2865                Some(t) => FunctionArgType::Typed(t),
2866                None => FunctionArgType::Raw(ty_token),
2867            };
2868            let default = match self.peek() {
2869                Token::ColonEq => {
2870                    self.advance();
2871                    Some(self.parse_expr(0)?)
2872                }
2873                Token::Eq => {
2874                    // PL/pgSQL also accepts `=` for the
2875                    // DECLARE default (PG treats them the same
2876                    // in this position).
2877                    self.advance();
2878                    Some(self.parse_expr(0)?)
2879                }
2880                _ => None,
2881            };
2882            // Mandatory `;` between declarations.
2883            if !matches!(self.peek(), Token::Semicolon) {
2884                return Err(self.err(alloc::format!(
2885                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2886                    self.peek()
2887                )));
2888            }
2889            self.advance();
2890            out.push(PlPgSqlDeclare { name, ty, default });
2891        }
2892    }
2893
2894    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2895    /// the terminating `END;` (or `END IF;` etc — handled by the
2896    /// per-construct sub-parsers). Used by both the outer block
2897    /// and the IF/ELSE branch bodies.
2898    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2899        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2900        loop {
2901            // Allow trailing semicolons + END.
2902            while matches!(self.peek(), Token::Semicolon) {
2903                self.advance();
2904            }
2905            // END / ELSE / ELSIF — handled by the caller.
2906            if matches!(
2907                self.peek(),
2908                Token::Ident(s) | Token::QuotedIdent(s)
2909                    if s.eq_ignore_ascii_case("end")
2910                        || s.eq_ignore_ascii_case("else")
2911                        || s.eq_ignore_ascii_case("elsif")
2912                        || s.eq_ignore_ascii_case("elseif")
2913            ) {
2914                return Ok(statements);
2915            }
2916            // Otherwise: one statement, then expect `;` or
2917            // a block-terminator keyword.
2918            let stmt = self.parse_plpgsql_stmt()?;
2919            statements.push(stmt);
2920            match self.peek() {
2921                Token::Semicolon => {
2922                    self.advance();
2923                }
2924                Token::Ident(s) | Token::QuotedIdent(s)
2925                    if s.eq_ignore_ascii_case("end")
2926                        || s.eq_ignore_ascii_case("else")
2927                        || s.eq_ignore_ascii_case("elsif")
2928                        || s.eq_ignore_ascii_case("elseif") =>
2929                {
2930                    // Final statement of the block without `;`.
2931                }
2932                other => {
2933                    return Err(self.err(alloc::format!(
2934                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2935                    )));
2936                }
2937            }
2938        }
2939    }
2940
2941    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2942        // RETURN keyword?
2943        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2944        {
2945            self.advance();
2946            return self.parse_plpgsql_return();
2947        }
2948        // v7.12.6 — IF block.
2949        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2950        {
2951            self.advance();
2952            return self.parse_plpgsql_if();
2953        }
2954        // v7.12.6 — RAISE.
2955        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2956        {
2957            self.advance();
2958            return self.parse_plpgsql_raise();
2959        }
2960        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2961        // plpgsql-specific shape (mailrs round-10 migrate-042).
2962        // PG's SELECT INTO at top-level SQL would CREATE a new
2963        // table; inside plpgsql it ASSIGNS the query result to
2964        // a local variable. We detect the INTO at paren-depth
2965        // 0 between SELECT and the statement boundary; if
2966        // found, split the token stream into "pre-INTO
2967        // projection" + "var" + "post-INTO FROM/WHERE…" and
2968        // rebuild as a SelectInto with a regular SELECT body
2969        // (no INTO clause).
2970        if matches!(self.peek(), Token::Select)
2971            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2972        {
2973            return Ok(PlPgSqlStmt::SelectInto {
2974                var: var_name,
2975                body: Box::new(select_body),
2976            });
2977        }
2978        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2979        // SELECT can appear directly inside a trigger body; we
2980        // recurse into the regular Statement parser, which will
2981        // stop at the trailing `;` (which our caller then
2982        // consumes).
2983        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2984        // also embed ALTER / CREATE / DROP statements; route
2985        // those through the same parser so the DO body parses
2986        // cleanly.
2987        if matches!(self.peek(), Token::Insert)
2988            || matches!(self.peek(), Token::Select)
2989            || matches!(self.peek(), Token::Create)
2990            || matches!(self.peek(), Token::Drop)
2991            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2992                if s.eq_ignore_ascii_case("update")
2993                    || s.eq_ignore_ascii_case("delete")
2994                    || s.eq_ignore_ascii_case("alter"))
2995        {
2996            let stmt = self.parse_one_statement()?;
2997            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2998        }
2999        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
3000        // followed by `:=` and an expression.
3001        let target = self.parse_plpgsql_assign_target()?;
3002        // PL/pgSQL assignment uses `:=`. The lexer represents
3003        // this as a colon followed by `=`; check both shapes.
3004        match self.peek() {
3005            Token::ColonEq => {
3006                self.advance();
3007            }
3008            Token::Colon => {
3009                self.advance();
3010                if !matches!(self.peek(), Token::Eq) {
3011                    return Err(self.err(alloc::format!(
3012                        "expected := after plpgsql assign target, got `:` then {:?}",
3013                        self.peek()
3014                    )));
3015                }
3016                self.advance();
3017            }
3018            other => {
3019                return Err(self.err(alloc::format!(
3020                    "expected := after plpgsql assign target, got {other:?}"
3021                )));
3022            }
3023        }
3024        let value = self.parse_expr(0)?;
3025        Ok(PlPgSqlStmt::Assign { target, value })
3026    }
3027
3028    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
3029    /// [ELSE body] END IF`. `IF` keyword already consumed.
3030    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3031        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
3032        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
3033        loop {
3034            // <expr> THEN
3035            let cond = self.parse_expr(0)?;
3036            let then_kw = self.expect_ident_like()?;
3037            if !then_kw.eq_ignore_ascii_case("then") {
3038                return Err(self.err(alloc::format!(
3039                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
3040                )));
3041            }
3042            let body = self.parse_plpgsql_stmt_list_until_end()?;
3043            branches.push((cond, body));
3044            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
3045            match self.peek() {
3046                Token::Ident(s) | Token::QuotedIdent(s)
3047                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
3048                {
3049                    self.advance();
3050                    continue;
3051                }
3052                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
3053                    self.advance();
3054                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
3055                    break;
3056                }
3057                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
3058                    break;
3059                }
3060                other => {
3061                    return Err(self.err(alloc::format!(
3062                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
3063                    )));
3064                }
3065            }
3066        }
3067        // Expect `END IF` (the END keyword is the one we're
3068        // looking at right now).
3069        let end_kw = self.expect_ident_like()?;
3070        if !end_kw.eq_ignore_ascii_case("end") {
3071            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
3072        }
3073        let if_kw = self.expect_ident_like()?;
3074        if !if_kw.eq_ignore_ascii_case("if") {
3075            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
3076        }
3077        Ok(PlPgSqlStmt::If {
3078            branches,
3079            else_branch,
3080        })
3081    }
3082
3083    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
3084    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
3085    /// is already consumed.
3086    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3087        let lvl_ident = self.expect_ident_like()?;
3088        let level = match lvl_ident.to_ascii_lowercase().as_str() {
3089            "notice" => RaiseLevel::Notice,
3090            "warning" => RaiseLevel::Warning,
3091            "info" => RaiseLevel::Info,
3092            "log" => RaiseLevel::Log,
3093            "debug" => RaiseLevel::Debug,
3094            "exception" => RaiseLevel::Exception,
3095            other => {
3096                return Err(self.err(alloc::format!(
3097                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
3098                )));
3099            }
3100        };
3101        // Message: required for v7.12.6. PG accepts a bare
3102        // RAISE-rethrow form (no message), reserved for future
3103        // RAISE-no-args support.
3104        let Token::String(msg) = self.peek() else {
3105            return Err(self.err(alloc::format!(
3106                "expected RAISE message string, got {:?}",
3107                self.peek()
3108            )));
3109        };
3110        let message = msg.clone();
3111        self.advance();
3112        // Optional comma-separated args (PG `%` format substitution).
3113        let mut args: Vec<Expr> = Vec::new();
3114        while matches!(self.peek(), Token::Comma) {
3115            self.advance();
3116            args.push(self.parse_expr(0)?);
3117        }
3118        Ok(PlPgSqlStmt::Raise {
3119            level,
3120            message,
3121            args,
3122        })
3123    }
3124
3125    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
3126    /// <projection> INTO <var> [FROM …]` (mailrs round-10
3127    /// migrate-042). Returns `(rebuilt_select_without_into,
3128    /// var_name)` when the pattern matches; `None` for
3129    /// regular SELECTs (those go through the embedded-SQL
3130    /// path). Token-stream surgery so the rebuilt SELECT
3131    /// parses through the regular `parse_select_stmt`.
3132    #[allow(clippy::too_many_lines)]
3133    fn try_parse_plpgsql_select_into(
3134        &mut self,
3135    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
3136        // Scan forward from `self.pos + 1` (past Token::Select)
3137        // for Token::Into at paren-depth 0, stopping at the
3138        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
3139        // end the plpgsql statement.
3140        let start = self.pos;
3141        let mut into_pos: Option<usize> = None;
3142        let mut depth: i32 = 0;
3143        let mut i = start + 1;
3144        while i < self.tokens.len() {
3145            match &self.tokens[i] {
3146                Token::LParen => depth += 1,
3147                Token::RParen => depth -= 1,
3148                Token::Semicolon if depth == 0 => break,
3149                Token::Ident(s)
3150                    if depth == 0
3151                        && (s.eq_ignore_ascii_case("end")
3152                            || s.eq_ignore_ascii_case("else")
3153                            || s.eq_ignore_ascii_case("elsif")) =>
3154                {
3155                    break;
3156                }
3157                Token::Into if depth == 0 => {
3158                    into_pos = Some(i);
3159                    break;
3160                }
3161                _ => {}
3162            }
3163            i += 1;
3164        }
3165        let Some(into_at) = into_pos else {
3166            return Ok(None);
3167        };
3168        // The token immediately after INTO must be the target
3169        // var ident; anything else (e.g. INSERT INTO table)
3170        // ruled out by the depth-0 check above. Capture it.
3171        let var = match self.tokens.get(into_at + 1) {
3172            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
3173            other => {
3174                return Err(self.err(alloc::format!(
3175                    "expected variable name after SELECT … INTO, got {other:?}"
3176                )));
3177            }
3178        };
3179        // Find the end of the plpgsql SELECT INTO statement —
3180        // same boundary rules as the depth-0 scan above.
3181        let mut end = into_at + 2;
3182        let mut depth2: i32 = 0;
3183        while end < self.tokens.len() {
3184            match &self.tokens[end] {
3185                Token::LParen => depth2 += 1,
3186                Token::RParen => depth2 -= 1,
3187                Token::Semicolon if depth2 == 0 => break,
3188                Token::Ident(s)
3189                    if depth2 == 0
3190                        && (s.eq_ignore_ascii_case("end")
3191                            || s.eq_ignore_ascii_case("else")
3192                            || s.eq_ignore_ascii_case("elsif")) =>
3193                {
3194                    break;
3195                }
3196                _ => {}
3197            }
3198            end += 1;
3199        }
3200        // Rebuild a token stream that represents the SELECT
3201        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
3202        // post-var tokens up to statement end]. Run the
3203        // regular `parse_select_stmt` against it.
3204        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
3205        for j in start..into_at {
3206            rebuilt.push(self.tokens[j].clone());
3207        }
3208        for j in (into_at + 2)..end {
3209            rebuilt.push(self.tokens[j].clone());
3210        }
3211        rebuilt.push(Token::Eof);
3212        let saved_pos = self.pos;
3213        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
3214        self.pos = 0;
3215        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
3216        if !matches!(self.peek(), Token::Select) {
3217            self.tokens = saved_tokens;
3218            self.pos = saved_pos;
3219            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
3220        }
3221        let sel = self.parse_select_stmt();
3222        self.tokens = saved_tokens;
3223        self.pos = end;
3224        let sel = sel?;
3225        let Statement::Select(body) = sel else {
3226            return Err(self.err(alloc::format!(
3227                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
3228            )));
3229        };
3230        Ok(Some((body, var)))
3231    }
3232
3233    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
3234        // v7.16.1 — read the head token DIRECTLY rather than
3235        // via `expect_ident_like`. The v7.14.0 schema-qualifier
3236        // strip (`public.t` → `t`) inside `expect_ident_like`
3237        // greedily consumes any `ident . ident` pair, which
3238        // silently turned every `NEW.col := …` /
3239        // `OLD.col := …` plpgsql assignment into a Local("col")
3240        // assignment — the head "new"/"old" was eaten as if it
3241        // were a schema name and the Dot was consumed too, so
3242        // this function's own `peek() == Token::Dot` check
3243        // below never fired. Every BEFORE trigger that rewrote
3244        // a NEW cell was a silent no-op for two major releases
3245        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
3246        // gate failures were investigated as v7.16.1 backlog.
3247        let head = match self.advance() {
3248            Token::Ident(s) | Token::QuotedIdent(s) => s,
3249            other => {
3250                return Err(self.err(alloc::format!(
3251                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
3252                )));
3253            }
3254        };
3255        if matches!(self.peek(), Token::Dot) {
3256            self.advance();
3257            let col = self.expect_ident_like()?;
3258            if head.eq_ignore_ascii_case("new") {
3259                return Ok(AssignTarget::NewColumn(col));
3260            }
3261            if head.eq_ignore_ascii_case("old") {
3262                return Ok(AssignTarget::OldColumn(col));
3263            }
3264            return Err(self.err(alloc::format!(
3265                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
3266                 got {head:?}.<col>"
3267            )));
3268        }
3269        Ok(AssignTarget::Local(head))
3270    }
3271
3272    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3273        // RETURN NEW / OLD / NULL — bare-ident forms.
3274        match self.peek() {
3275            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
3276                self.advance();
3277                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
3278            }
3279            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
3280                self.advance();
3281                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
3282            }
3283            Token::Null => {
3284                self.advance();
3285                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3286            }
3287            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
3288            // per PL/pgSQL convention.
3289            Token::Semicolon => {
3290                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3291            }
3292            _ => {}
3293        }
3294        // Fall through: parse a full expression.
3295        let e = self.parse_expr(0)?;
3296        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
3297    }
3298
3299    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
3300        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
3301        // are ident-shaped (the parser keys off case-insensitive
3302        // match — same shape used by the top-level Update / Delete
3303        // dispatchers at parse_one_statement).
3304        if matches!(self.peek(), Token::Insert) {
3305            self.advance();
3306            return Ok(TriggerEvent::Insert);
3307        }
3308        match self.peek() {
3309            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3310                self.advance();
3311                Ok(TriggerEvent::Update)
3312            }
3313            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3314                self.advance();
3315                Ok(TriggerEvent::Delete)
3316            }
3317            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
3318                self.advance();
3319                Ok(TriggerEvent::Truncate)
3320            }
3321            other => Err(self.err(alloc::format!(
3322                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3323            ))),
3324        }
3325    }
3326
3327    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3328    ///   - (no clause) → implicit `FOR ALL TABLES`
3329    ///   - `FOR ALL TABLES`
3330    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3331    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3332    ///     accepted (PG accepts both forms in PG 19).
3333    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3334        let name = self.expect_ident_or_string()?;
3335        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3336        // shape so existing publications keep parsing identically.
3337        let scope = if matches!(self.peek(), Token::For) {
3338            self.advance();
3339            if matches!(self.peek(), Token::All) {
3340                self.advance();
3341                if !matches!(self.peek(), Token::Tables) {
3342                    return Err(self.err(format!(
3343                        "expected TABLES after FOR ALL, got {:?}",
3344                        self.peek()
3345                    )));
3346                }
3347                self.advance();
3348                if matches!(self.peek(), Token::Except) {
3349                    self.advance();
3350                    let tables = self.parse_publication_table_list()?;
3351                    PublicationScope::AllTablesExcept(tables)
3352                } else {
3353                    PublicationScope::AllTables
3354                }
3355            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3356                // PG 19 accepts both `FOR TABLE …` (singular) and
3357                // `FOR TABLES …` (plural); SPG matches.
3358                self.advance();
3359                let tables = self.parse_publication_table_list()?;
3360                PublicationScope::ForTables(tables)
3361            } else {
3362                return Err(self.err(format!(
3363                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3364                    self.peek()
3365                )));
3366            }
3367        } else {
3368            PublicationScope::AllTables
3369        };
3370        Ok(Statement::CreatePublication(CreatePublicationStatement {
3371            name,
3372            scope,
3373        }))
3374    }
3375
3376    /// v6.1.3 — Comma-separated identifier list for the publication
3377    /// FOR-clause. Requires at least one entry; empty list is a
3378    /// parse error (PG behaviour). Quoted idents are accepted; the
3379    /// names round-trip through `Display` as `quote_ident(name)`.
3380    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3381        let first = self.expect_ident_like()?;
3382        let mut out = alloc::vec![first];
3383        while matches!(self.peek(), Token::Comma) {
3384            self.advance();
3385            out.push(self.expect_ident_like()?);
3386        }
3387        Ok(out)
3388    }
3389
3390    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3391    ///                 CONNECTION '<conn>'
3392    ///                 PUBLICATION <pub> [, <pub> ...]`.
3393    ///
3394    /// The clause order is fixed (CONNECTION first, then
3395    /// PUBLICATION) to match PG. No WITH-options accepted in
3396    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3397    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3398        let name = self.expect_ident_or_string()?;
3399        if !matches!(self.peek(), Token::Connection) {
3400            return Err(self.err(format!(
3401                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3402                self.peek()
3403            )));
3404        }
3405        self.advance();
3406        let conn_str = self.expect_string_literal()?;
3407        if !matches!(self.peek(), Token::Publication) {
3408            return Err(self.err(format!(
3409                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3410                self.peek()
3411            )));
3412        }
3413        self.advance();
3414        // Reuse the publication FOR-list parser shape: at least one
3415        // identifier, comma-separated.
3416        let first = self.expect_ident_like()?;
3417        let mut publications = alloc::vec![first];
3418        while matches!(self.peek(), Token::Comma) {
3419            self.advance();
3420            publications.push(self.expect_ident_like()?);
3421        }
3422        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3423            name,
3424            conn_str,
3425            publications,
3426        }))
3427    }
3428
3429    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3430    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3431    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3432    /// that fit `u64`.
3433    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3434    /// (`pg_catalog.default_text_search_config` etc).
3435    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3436        let mut name = self.expect_ident_like()?;
3437        while matches!(self.peek(), Token::Dot) {
3438            self.advance();
3439            let next = self.expect_ident_like()?;
3440            name.push('.');
3441            name.push_str(&next);
3442        }
3443        Ok(name.to_ascii_lowercase())
3444    }
3445
3446    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3447        match self.advance() {
3448            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3449            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3450                Ok(crate::ast::SetValue::Default)
3451            }
3452            Token::Ident(s) | Token::QuotedIdent(s) => {
3453                let mut accum = s;
3454                while matches!(self.peek(), Token::Dot) {
3455                    self.advance();
3456                    let next = self.expect_ident_like()?;
3457                    accum.push('.');
3458                    accum.push_str(&next);
3459                }
3460                Ok(crate::ast::SetValue::Ident(accum))
3461            }
3462            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3463            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3464            // v7.22 (mailrs round-13 gap 2) — PG boolean parameter
3465            // spellings that lex as keyword tokens, not idents:
3466            // `SET standard_conforming_strings = on` is in every
3467            // pg_dump preamble (`off` already lexes as an ident).
3468            Token::On => Ok(crate::ast::SetValue::Ident("on".to_string())),
3469            Token::True => Ok(crate::ast::SetValue::Ident("true".to_string())),
3470            Token::False => Ok(crate::ast::SetValue::Ident("false".to_string())),
3471            // v7.14.0 — MySQL session/user variable RHS
3472            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3473            // Wrap as Ident so the SET handler can record it; the
3474            // engine treats `@VAR` / `@@VAR` values as opaque
3475            // strings.
3476            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3477            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3478            // is the common MySQL preamble shape. Allow a `+` or
3479            // `-` prefix on negative numerics for parity with PG
3480            // (some param defaults are negative).
3481            Token::Minus => match self.advance() {
3482                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3483                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3484                other => Err(self.err(format!(
3485                    "expected numeric after `-` in SET value, got {other:?}"
3486                ))),
3487            },
3488            other => Err(self.err(format!(
3489                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3490            ))),
3491        }
3492    }
3493
3494    /// v7.38 轴 4 — `[ISOLATION LEVEL …] [READ ONLY|WRITE]
3495    /// [[NOT] DEFERRABLE]` modes after `SET TRANSACTION` or
3496    /// `START TRANSACTION` / `BEGIN`. Returns the isolation level
3497    /// (default `ReadCommitted` if no `ISOLATION LEVEL` clause was
3498    /// present). Modes are comma-separated per PG; SPG also
3499    /// accepts space-separated for tolerance. READ ONLY / WRITE
3500    /// / DEFERRABLE are parsed-and-ignored (recorded for future
3501    /// surface but not behaviorally honoured today).
3502    fn parse_isolation_level_clauses(&mut self) -> Result<IsolationLevel, ParseError> {
3503        let mut level = IsolationLevel::default();
3504        let mut have_level = false;
3505        loop {
3506            // ISOLATION LEVEL …
3507            let saw_isolation = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("isolation"));
3508            if saw_isolation {
3509                self.advance(); // ISOLATION
3510                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("level"))
3511                {
3512                    return Err(self.err(alloc::format!(
3513                        "expected LEVEL after ISOLATION, got {:?}",
3514                        self.peek()
3515                    )));
3516                }
3517                self.advance(); // LEVEL
3518                // SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED
3519                let w1 = self
3520                    .expect_ident_like()
3521                    .map_err(|e| self.err(alloc::format!("isolation level: {e:?}")))?;
3522                let lc = w1.to_ascii_lowercase();
3523                level = match lc.as_str() {
3524                    "serializable" => IsolationLevel::Serializable,
3525                    "repeatable" => {
3526                        // Expect READ
3527                        let w2 = self
3528                            .expect_ident_like()
3529                            .map_err(|e| self.err(alloc::format!("REPEATABLE …: {e:?}")))?;
3530                        if !w2.eq_ignore_ascii_case("read") {
3531                            return Err(self.err(alloc::format!(
3532                                "expected READ after REPEATABLE, got {w2:?}"
3533                            )));
3534                        }
3535                        IsolationLevel::RepeatableRead
3536                    }
3537                    "read" => {
3538                        let w2 = self
3539                            .expect_ident_like()
3540                            .map_err(|e| self.err(alloc::format!("READ …: {e:?}")))?;
3541                        match w2.to_ascii_lowercase().as_str() {
3542                            "committed" => IsolationLevel::ReadCommitted,
3543                            "uncommitted" => IsolationLevel::ReadUncommitted,
3544                            other => {
3545                                return Err(self.err(alloc::format!(
3546                                    "expected COMMITTED or UNCOMMITTED after READ, got {other:?}"
3547                                )));
3548                            }
3549                        }
3550                    }
3551                    other => {
3552                        return Err(self.err(alloc::format!(
3553                            "unknown isolation level {other:?}"
3554                        )));
3555                    }
3556                };
3557                have_level = true;
3558            } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("read"))
3559            {
3560                // READ ONLY | READ WRITE — parsed, not behaviorally honoured.
3561                self.advance();
3562                match self.peek().clone() {
3563                    Token::Ident(s) if s.eq_ignore_ascii_case("only") => {
3564                        self.advance();
3565                    }
3566                    Token::Ident(s) if s.eq_ignore_ascii_case("write") => {
3567                        self.advance();
3568                    }
3569                    other => {
3570                        return Err(self.err(alloc::format!(
3571                            "expected ONLY or WRITE after READ, got {other:?}"
3572                        )));
3573                    }
3574                }
3575            } else if matches!(self.peek(), Token::Not) {
3576                // NOT DEFERRABLE — `NOT` lexes as a reserved keyword.
3577                self.advance();
3578                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable"))
3579                {
3580                    return Err(self.err(alloc::format!(
3581                        "expected DEFERRABLE after NOT, got {:?}",
3582                        self.peek()
3583                    )));
3584                }
3585                self.advance();
3586            } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable"))
3587            {
3588                self.advance();
3589            } else {
3590                break;
3591            }
3592            // Optional comma between modes.
3593            if matches!(self.peek(), Token::Comma) {
3594                self.advance();
3595            }
3596        }
3597        let _ = have_level;
3598        Ok(level)
3599    }
3600
3601    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3602        // FOR is a v6.1.2-reserved keyword (Token::For). The
3603        // other two are bare idents — they've never needed lexer
3604        // support and we keep it that way.
3605        if !matches!(self.peek(), Token::For) {
3606            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3607        }
3608        self.advance();
3609        self.expect_keyword_ident("wal")?;
3610        self.expect_keyword_ident("position")?;
3611        let pos = self.expect_u64_literal()?;
3612        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3613        {
3614            self.advance();
3615            self.expect_keyword_ident("timeout")?;
3616            Some(self.expect_u64_literal()?)
3617        } else {
3618            None
3619        };
3620        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3621    }
3622
3623    /// v6.1.7 helper — consume a `Token::Integer` and check it
3624    /// fits `u64`. WAL positions and millisecond timeouts are
3625    /// non-negative.
3626    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3627        match self.advance() {
3628            Token::Integer(n) if n >= 0 => Ok(n as u64),
3629            Token::Integer(n) => Err(ParseError {
3630                message: format!("expected non-negative integer, got {n}"),
3631                token_pos: self.pos.saturating_sub(1),
3632            }),
3633            other => Err(ParseError {
3634                message: format!("expected integer literal, got {other:?}"),
3635                token_pos: self.pos.saturating_sub(1),
3636            }),
3637        }
3638    }
3639
3640    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3641    /// ROLE '<role>' (defaults to readonly). All string slots accept
3642    /// either a quoted ident or a quoted string literal.
3643    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3644        let name = self.expect_ident_or_string()?;
3645        self.expect_keyword_ident("with")?;
3646        self.expect_keyword_ident("password")?;
3647        let password = self.expect_string_literal()?;
3648        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3649            && s.eq_ignore_ascii_case("role")
3650        {
3651            self.advance();
3652            self.expect_string_literal()?
3653        } else {
3654            "readonly".to_string()
3655        };
3656        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3657            name,
3658            password,
3659            role,
3660        }))
3661    }
3662
3663    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3664    /// Caller already consumed the leading `UPDATE` ident.
3665    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3666        let table = self.expect_ident_like()?;
3667        self.expect_keyword_ident("set")?;
3668        let mut assignments = Vec::new();
3669        loop {
3670            let col = self.expect_ident_like()?;
3671            if !matches!(self.peek(), Token::Eq) {
3672                return Err(self.err(format!(
3673                    "expected `=` after column name in UPDATE SET, got {:?}",
3674                    self.peek()
3675                )));
3676            }
3677            self.advance();
3678            let value = self.parse_expr(0)?;
3679            assignments.push((col, value));
3680            if matches!(self.peek(), Token::Comma) {
3681                self.advance();
3682                continue;
3683            }
3684            break;
3685        }
3686        let where_ = if matches!(self.peek(), Token::Where) {
3687            self.advance();
3688            Some(self.parse_expr(0)?)
3689        } else {
3690            None
3691        };
3692        let returning = self.parse_optional_returning()?;
3693        Ok(Statement::Update(crate::ast::UpdateStatement {
3694            ctes: Vec::new(),
3695            table,
3696            assignments,
3697            where_,
3698            returning,
3699        }))
3700    }
3701
3702    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3703    /// the leading `DELETE` ident.
3704    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3705        if !matches!(self.peek(), Token::From) {
3706            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3707        }
3708        self.advance();
3709        let table = self.expect_ident_like()?;
3710        let where_ = if matches!(self.peek(), Token::Where) {
3711            self.advance();
3712            Some(self.parse_expr(0)?)
3713        } else {
3714            None
3715        };
3716        let returning = self.parse_optional_returning()?;
3717        Ok(Statement::Delete(crate::ast::DeleteStatement {
3718            ctes: Vec::new(),
3719            table,
3720            where_,
3721            returning,
3722        }))
3723    }
3724
3725    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3726    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3727    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3728    /// keyword. v7.17 surface:
3729    ///   * source: table reference (subquery source is a follow-up)
3730    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3731    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3732    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3733    ///     order
3734    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3735        // INTO
3736        let is_into_kw = matches!(self.peek(), Token::Into)
3737            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3738        if !is_into_kw {
3739            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3740        }
3741        self.advance();
3742        let target = self.expect_ident_like()?;
3743        // Optional alias — bare ident before USING.
3744        let target_alias = match self.peek() {
3745            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3746                Some(self.expect_ident_like()?)
3747            }
3748            _ => None,
3749        };
3750        // USING
3751        let is_using_kw = matches!(
3752            self.peek(),
3753            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3754        );
3755        if !is_using_kw {
3756            return Err(self.err(format!(
3757                "expected USING after MERGE INTO target, got {:?}",
3758                self.peek()
3759            )));
3760        }
3761        self.advance();
3762        let source = self.expect_ident_like()?;
3763        let source_alias = match self.peek() {
3764            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3765                Some(self.expect_ident_like()?)
3766            }
3767            _ => None,
3768        };
3769        // ON
3770        if !matches!(self.peek(), Token::On) {
3771            return Err(self.err(format!(
3772                "expected ON after MERGE … USING source, got {:?}",
3773                self.peek()
3774            )));
3775        }
3776        self.advance();
3777        let on = self.parse_expr(0)?;
3778        // One or more WHEN clauses.
3779        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3780        loop {
3781            let is_when_kw = matches!(
3782                self.peek(),
3783                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3784            );
3785            if !is_when_kw {
3786                break;
3787            }
3788            self.advance(); // WHEN
3789            // [NOT] MATCHED
3790            let matched = if matches!(self.peek(), Token::Not) {
3791                self.advance();
3792                crate::ast::MergeMatched::NotMatched
3793            } else {
3794                crate::ast::MergeMatched::Matched
3795            };
3796            let is_matched_kw = matches!(
3797                self.peek(),
3798                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3799            );
3800            if !is_matched_kw {
3801                return Err(self.err(format!(
3802                    "expected MATCHED in WHEN clause, got {:?}",
3803                    self.peek()
3804                )));
3805            }
3806            self.advance();
3807            // Optional AND <expr>
3808            let condition = if matches!(self.peek(), Token::And) {
3809                self.advance();
3810                Some(self.parse_expr(0)?)
3811            } else {
3812                None
3813            };
3814            // THEN
3815            let is_then_kw = matches!(
3816                self.peek(),
3817                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3818            );
3819            if !is_then_kw {
3820                return Err(self.err(format!(
3821                    "expected THEN in WHEN clause, got {:?}",
3822                    self.peek()
3823                )));
3824            }
3825            self.advance();
3826            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3827            let action = match self.peek().clone() {
3828                Token::Insert => {
3829                    self.advance();
3830                    // (cols)
3831                    if !matches!(self.peek(), Token::LParen) {
3832                        return Err(self.err(format!(
3833                            "expected '(' after INSERT in MERGE, got {:?}",
3834                            self.peek()
3835                        )));
3836                    }
3837                    self.advance();
3838                    let mut columns: Vec<String> = Vec::new();
3839                    loop {
3840                        columns.push(self.expect_ident_like()?);
3841                        if matches!(self.peek(), Token::Comma) {
3842                            self.advance();
3843                            continue;
3844                        }
3845                        break;
3846                    }
3847                    if !matches!(self.peek(), Token::RParen) {
3848                        return Err(self.err(format!(
3849                            "expected ')' after INSERT column list, got {:?}",
3850                            self.peek()
3851                        )));
3852                    }
3853                    self.advance();
3854                    // VALUES (...)
3855                    if !matches!(self.peek(), Token::Values) {
3856                        return Err(self.err(format!(
3857                            "expected VALUES in MERGE INSERT, got {:?}",
3858                            self.peek()
3859                        )));
3860                    }
3861                    self.advance();
3862                    if !matches!(self.peek(), Token::LParen) {
3863                        return Err(self.err(format!(
3864                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3865                            self.peek()
3866                        )));
3867                    }
3868                    self.advance();
3869                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3870                    loop {
3871                        values.push(self.parse_expr(0)?);
3872                        if matches!(self.peek(), Token::Comma) {
3873                            self.advance();
3874                            continue;
3875                        }
3876                        break;
3877                    }
3878                    if !matches!(self.peek(), Token::RParen) {
3879                        return Err(self.err(format!(
3880                            "expected ')' after MERGE INSERT values, got {:?}",
3881                            self.peek()
3882                        )));
3883                    }
3884                    self.advance();
3885                    if columns.len() != values.len() {
3886                        return Err(self.err(format!(
3887                            "MERGE INSERT column count ({}) ≠ value count ({})",
3888                            columns.len(),
3889                            values.len()
3890                        )));
3891                    }
3892                    crate::ast::MergeAction::Insert { columns, values }
3893                }
3894                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3895                    self.advance();
3896                    // SET
3897                    let is_set_kw = matches!(
3898                        self.peek(),
3899                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3900                    );
3901                    if !is_set_kw {
3902                        return Err(self.err(format!(
3903                            "expected SET after UPDATE in MERGE, got {:?}",
3904                            self.peek()
3905                        )));
3906                    }
3907                    self.advance();
3908                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3909                    loop {
3910                        let col = self.expect_ident_like()?;
3911                        if !matches!(self.peek(), Token::Eq) {
3912                            return Err(self.err(format!(
3913                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3914                                self.peek()
3915                            )));
3916                        }
3917                        self.advance();
3918                        let expr = self.parse_expr(0)?;
3919                        assignments.push((col, expr));
3920                        if matches!(self.peek(), Token::Comma) {
3921                            self.advance();
3922                            continue;
3923                        }
3924                        break;
3925                    }
3926                    crate::ast::MergeAction::Update { assignments }
3927                }
3928                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3929                    self.advance();
3930                    crate::ast::MergeAction::Delete
3931                }
3932                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3933                    self.advance();
3934                    let is_nothing_kw = matches!(
3935                        self.peek(),
3936                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3937                    );
3938                    if !is_nothing_kw {
3939                        return Err(self.err(format!(
3940                            "expected NOTHING after DO in MERGE clause, got {:?}",
3941                            self.peek()
3942                        )));
3943                    }
3944                    self.advance();
3945                    crate::ast::MergeAction::DoNothing
3946                }
3947                other => {
3948                    return Err(self.err(format!(
3949                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3950                    )));
3951                }
3952            };
3953            clauses.push(crate::ast::MergeWhenClause {
3954                matched,
3955                condition,
3956                action,
3957            });
3958        }
3959        if clauses.is_empty() {
3960            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3961        }
3962        Ok(Statement::Merge(crate::ast::MergeStatement {
3963            target,
3964            target_alias,
3965            source,
3966            source_alias,
3967            on,
3968            clauses,
3969        }))
3970    }
3971
3972    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3973    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3974    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3975    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3976    fn parse_optional_returning(
3977        &mut self,
3978    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3979        let is_returning_kw = matches!(
3980            self.peek(),
3981            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3982        );
3983        if !is_returning_kw {
3984            return Ok(None);
3985        }
3986        self.advance();
3987        let mut items = Vec::new();
3988        loop {
3989            items.push(self.parse_select_item()?);
3990            if matches!(self.peek(), Token::Comma) {
3991                self.advance();
3992                continue;
3993            }
3994            break;
3995        }
3996        Ok(Some(items))
3997    }
3998
3999    /// v6.0.4 — parse the tail of an ALTER statement after the
4000    /// leading `ALTER` keyword has been consumed. Only one form is
4001    /// supported in v6.0.4:
4002    ///
4003    /// ```text
4004    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
4005    /// ```
4006    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
4007        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
4008        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
4009        // exclusion) is accepted by stripping the `ONLY` keyword
4010        // before the table parse.
4011        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
4012        // and the long PG-dump tail are accepted as no-ops.
4013        match self.advance() {
4014            Token::Index => {}
4015            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
4016            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
4017            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
4018            Token::Table => {
4019                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
4020                    self.advance();
4021                }
4022                return self.parse_alter_table_after_keyword();
4023            }
4024            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
4025                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
4026                    self.advance();
4027                }
4028                return self.parse_alter_table_after_keyword();
4029            }
4030            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
4031            // of the silent-noop tail.
4032            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
4033                return self.parse_alter_sequence_after_keyword();
4034            }
4035            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
4036            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
4037            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
4038            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
4039            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
4040            Token::Ident(s) | Token::QuotedIdent(s)
4041                if matches!(
4042                    s.to_ascii_lowercase().as_str(),
4043                    "view"
4044                        | "function"
4045                        | "type"
4046                        | "domain"
4047                        | "database"
4048                        | "role"
4049                        | "schema"
4050                        | "owner"
4051                        | "default"
4052                        | "extension"
4053                        | "materialized"
4054                        | "policy"
4055                        | "publication"
4056                        | "subscription"
4057                ) =>
4058            {
4059                self.consume_until_statement_boundary();
4060                return Ok(Statement::Empty);
4061            }
4062            other => {
4063                return Err(self.err(format!(
4064                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
4065                     after ALTER, got {other:?}"
4066                )));
4067            }
4068        }
4069        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
4070        // (mailrs migrate-042 ships these). The presence of an
4071        // IF EXISTS makes the subsequent name lookup tolerate
4072        // a missing index — engine returns CommandOk no-op.
4073        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4074            let next = self.tokens.get(self.pos + 1);
4075            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4076                self.advance();
4077                self.advance();
4078                true
4079            } else {
4080                false
4081            }
4082        } else {
4083            false
4084        };
4085        let name = self.expect_ident_like()?;
4086        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
4087        // Detect BEFORE the REBUILD path so the existing REBUILD
4088        // arm stays untouched.
4089        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
4090            self.advance();
4091            if matches!(self.peek(), Token::To) {
4092                self.advance();
4093            } else {
4094                self.expect_keyword_ident("to")?;
4095            }
4096            let new = self.expect_ident_like()?;
4097            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
4098                name,
4099                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
4100            }));
4101        }
4102        // REBUILD
4103        self.expect_keyword_ident("rebuild")?;
4104        // Optional: WITH (encoding = <enc>)
4105        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
4106            self.advance();
4107            if !matches!(self.peek(), Token::LParen) {
4108                return Err(self.err(format!(
4109                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
4110                    self.peek()
4111                )));
4112            }
4113            self.advance();
4114            self.expect_keyword_ident("encoding")?;
4115            if !matches!(self.peek(), Token::Eq) {
4116                return Err(self.err(format!(
4117                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
4118                    self.peek()
4119                )));
4120            }
4121            self.advance();
4122            let enc_ident = match self.advance() {
4123                Token::Ident(s) | Token::QuotedIdent(s) => s,
4124                other => {
4125                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
4126                }
4127            };
4128            let enc = match enc_ident.to_ascii_lowercase().as_str() {
4129                "f32" => VecEncoding::F32,
4130                "sq8" => VecEncoding::Sq8,
4131                "half" => VecEncoding::F16,
4132                other => {
4133                    return Err(self.err(format!(
4134                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
4135                    )));
4136                }
4137            };
4138            if !matches!(self.peek(), Token::RParen) {
4139                return Err(self.err(format!(
4140                    "expected ')' after encoding value, got {:?}",
4141                    self.peek()
4142                )));
4143            }
4144            self.advance();
4145            Some(enc)
4146        } else {
4147            None
4148        };
4149        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
4150            name,
4151            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
4152        }))
4153    }
4154
4155    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
4156    /// only `SET` form currently supported; future v6.7.x can add
4157    /// more SET subjects without changing the dispatch shape.
4158    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
4159    /// subactions. Single-subaction shape stays a 1-element vec.
4160    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
4161        let table_name = self.expect_ident_like()?;
4162        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
4163        loop {
4164            let subaction = self.parse_alter_table_subaction()?;
4165            // ADD COLUMN with inline REFERENCES emits both an
4166            // AddColumn and an AddForeignKey subaction; the
4167            // helper returns 1 or 2 items.
4168            targets.extend(subaction);
4169            if matches!(self.peek(), Token::Comma) {
4170                self.advance();
4171                continue;
4172            }
4173            break;
4174        }
4175        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
4176            name: table_name,
4177            targets,
4178        }))
4179    }
4180
4181    /// Parse one ALTER TABLE subaction. Returns a Vec because
4182    /// inline `REFERENCES` on `ADD COLUMN` produces both an
4183    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
4184    fn parse_alter_table_subaction(
4185        &mut self,
4186    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
4187        match self.peek() {
4188            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4189                self.advance();
4190                let setting = self.expect_ident_like()?;
4191                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
4192                    return Err(self.err(alloc::format!(
4193                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
4194                    )));
4195                }
4196                if !matches!(self.peek(), Token::Eq) {
4197                    return Err(self.err(alloc::format!(
4198                        "expected '=' after hot_tier_bytes, got {:?}",
4199                        self.peek()
4200                    )));
4201                }
4202                self.advance();
4203                let n = self.expect_u64_literal()?;
4204                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
4205            }
4206            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4207                self.advance();
4208                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
4209                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
4210                // PRIMARY KEY this way; mysqldump emits both.
4211                // Peek-only dispatch (no advance) — `advance()`
4212                // destructively replaces consumed tokens with Eof,
4213                // so saved-pos restore would land on Eofs.
4214                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
4215                {
4216                    // The next-but-one ident is the constraint
4217                    // name; the one after THAT is the kind.
4218                    let kind_pos = self.pos + 2;
4219                    let kind = self.tokens.get(kind_pos).cloned();
4220                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
4221                    {
4222                        let fk = self.parse_table_level_fk()?;
4223                        return Ok(alloc::vec![
4224                            crate::ast::AlterTableTarget::AddForeignKey(fk)
4225                        ]);
4226                    }
4227                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
4228                    {
4229                        self.advance(); // CONSTRAINT
4230                        let _name = self.expect_ident_like()?;
4231                        self.advance(); // PRIMARY
4232                        self.expect_keyword_ident("key")?;
4233                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4234                        return Ok(alloc::vec![
4235                            crate::ast::AlterTableTarget::AddTableConstraint(
4236                                crate::ast::TableConstraint::PrimaryKey {
4237                                    name: None,
4238                                    columns: cols,
4239                                }
4240                            )
4241                        ]);
4242                    }
4243                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
4244                    {
4245                        self.advance(); // CONSTRAINT
4246                        let _name = self.expect_ident_like()?;
4247                        // v7.22 (mailrs round-13 gap 6) — delegate so
4248                        // the optional `NULLS [NOT] DISTINCT` modifier
4249                        // parses here too (pg_dump emits the ALTER
4250                        // form; semantics enforced by the engine
4251                        // since v7.13).
4252                        let uc = self.parse_table_level_unique()?;
4253                        return Ok(alloc::vec![
4254                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4255                        ]);
4256                    }
4257                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
4258                    {
4259                        self.advance(); // CONSTRAINT
4260                        let _name = self.expect_ident_like()?;
4261                        self.advance(); // CHECK
4262                        if !matches!(self.peek(), Token::LParen) {
4263                            return Err(self.err(alloc::format!(
4264                                "expected '(' after CHECK, got {:?}", self.peek()
4265                            )));
4266                        }
4267                        self.advance();
4268                        let expr = self.parse_expr(0)?;
4269                        if matches!(self.peek(), Token::RParen) {
4270                            self.advance();
4271                        }
4272                        return Ok(alloc::vec![
4273                            crate::ast::AlterTableTarget::AddTableConstraint(
4274                                crate::ast::TableConstraint::Check { name: None, expr }
4275                            )
4276                        ]);
4277                    }
4278                    // Unknown kind — fall through to FK path which
4279                    // produces a descriptive parse error.
4280                }
4281                let is_fk = matches!(
4282                    self.peek(),
4283                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
4284                        || s.eq_ignore_ascii_case("foreign")
4285                );
4286                if is_fk {
4287                    let fk = self.parse_table_level_fk()?;
4288                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
4289                }
4290                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
4291                // (no CONSTRAINT prefix) — same dispatch.
4292                match self.peek().clone() {
4293                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
4294                        self.advance();
4295                        self.expect_keyword_ident("key")?;
4296                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4297                        return Ok(alloc::vec![
4298                            crate::ast::AlterTableTarget::AddTableConstraint(
4299                                crate::ast::TableConstraint::PrimaryKey {
4300                                    name: None,
4301                                    columns: cols,
4302                                }
4303                            )
4304                        ]);
4305                    }
4306                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4307                        // v7.22 — delegate (NULLS [NOT] DISTINCT).
4308                        let uc = self.parse_table_level_unique()?;
4309                        return Ok(alloc::vec![
4310                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4311                        ]);
4312                    }
4313                    _ => {}
4314                }
4315                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4316                    self.advance();
4317                }
4318                let mut if_not_exists = false;
4319                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4320                    self.advance();
4321                    if !matches!(self.peek(), Token::Not) {
4322                        return Err(self.err(alloc::format!(
4323                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
4324                            self.peek()
4325                        )));
4326                    }
4327                    self.advance();
4328                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
4329                        return Err(self.err(alloc::format!(
4330                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
4331                            self.peek()
4332                        )));
4333                    }
4334                    self.advance();
4335                    if_not_exists = true;
4336                }
4337                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
4338                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
4339                // returns ColumnDef + an optional inline FK.
4340                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
4341                let col_name = column.name.clone();
4342                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
4343                    column,
4344                    if_not_exists,
4345                }];
4346                if let Some(mut fk) = col_level_fk {
4347                    if fk.columns.is_empty() {
4348                        fk.columns.push(col_name);
4349                    }
4350                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
4351                }
4352                Ok(out)
4353            }
4354            Token::Drop => {
4355                self.advance();
4356                // v7.13.3 — dispatch on the next token. mailrs round-7
4357                // S8 closed DROP COLUMN; round-6 S7 closed
4358                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
4359                // RESTRICT modifiers.
4360                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
4361                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
4362                let subject = match self.peek() {
4363                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
4364                        self.advance();
4365                        "constraint"
4366                    }
4367                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
4368                        self.advance();
4369                        "column"
4370                    }
4371                    // PG-canonical bare `DROP <col>` without COLUMN
4372                    // keyword is also valid; treat any other ident
4373                    // as the column name.
4374                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
4375                    other => {
4376                        return Err(self.err(alloc::format!(
4377                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
4378                        )));
4379                    }
4380                };
4381                let mut if_exists = false;
4382                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4383                    let n1 = self.tokens.get(self.pos + 1);
4384                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4385                        self.advance();
4386                        self.advance();
4387                        if_exists = true;
4388                    }
4389                }
4390                let name = self.expect_ident_like()?;
4391                let mut cascade = false;
4392                if matches!(
4393                    self.peek(),
4394                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
4395                        || s.eq_ignore_ascii_case("restrict")
4396                ) {
4397                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
4398                    {
4399                        cascade = true;
4400                    }
4401                    self.advance();
4402                }
4403                if subject == "constraint" {
4404                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
4405                        name,
4406                        if_exists,
4407                    }])
4408                } else {
4409                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
4410                        column: name,
4411                        if_exists,
4412                        cascade,
4413                    }])
4414                }
4415            }
4416            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
4417                self.advance();
4418                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4419                    self.advance();
4420                }
4421                let col_name = self.expect_ident_like()?;
4422                match self.peek() {
4423                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
4424                        self.advance();
4425                    }
4426                    // v7.14.0 — pg_dump emits BIGSERIAL via
4427                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
4428                    // nextval('seq')` (the sequence is created
4429                    // separately). SPG's BIGSERIAL already uses
4430                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4431                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4432                    // engine no-ops by consuming the tail.
4433                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4434                        // v7.22 (round-13 T2) — `SET DEFAULT
4435                        // nextval('…')` is how pg_dump spells a
4436                        // SERIAL column (plain integer in CREATE
4437                        // TABLE + this ALTER). It used to be
4438                        // swallowed as a no-op, which silently
4439                        // STRIPPED auto-increment from imported
4440                        // schemas — the first post-import INSERT
4441                        // without an explicit id then violated NOT
4442                        // NULL. Lower it to the auto-increment
4443                        // marker instead.
4444                        let is_default_nextval =
4445                            matches!(self.tokens.get(self.pos + 1), Some(Token::Default))
4446                                && matches!(
4447                                    self.tokens.get(self.pos + 2),
4448                                    Some(Token::Ident(f)) if f.eq_ignore_ascii_case("nextval")
4449                                );
4450                        // Capture the nextval target so the engine
4451                        // can guarantee the sequence exists.
4452                        let seq_name = if is_default_nextval {
4453                            self.scan_sequence_name_until_boundary()
4454                        } else {
4455                            self.consume_until_statement_boundary();
4456                            None
4457                        };
4458                        if is_default_nextval {
4459                            return Ok(alloc::vec![
4460                                crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4461                                    column: col_name,
4462                                    seq_name,
4463                                }
4464                            ]);
4465                        }
4466                        // Other SET DEFAULT … / SET NOT NULL forms
4467                        // stay engine no-ops (real defaults arrive
4468                        // inline in CREATE TABLE in every dump;
4469                        // nullability change would need a row scan
4470                        // — deferred).
4471                        return Ok(Vec::new());
4472                    }
4473                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4474                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4475                        self.consume_until_statement_boundary();
4476                        return Ok(Vec::new());
4477                    }
4478                    Token::Drop => {
4479                        // v7.37.43-T4 — same path as the Ident("drop")
4480                        // arm above. `DROP` is unreserved per PG; the
4481                        // lexer emits `Token::Drop` so the publication-
4482                        // DROP path can dispatch on it, but ALTER COLUMN
4483                        // DROP DEFAULT / DROP NOT NULL must also work.
4484                        self.consume_until_statement_boundary();
4485                        return Ok(Vec::new());
4486                    }
4487                    Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4488                        // v7.22 (round-13 T2) — `ALTER COLUMN c ADD
4489                        // GENERATED { ALWAYS | BY DEFAULT } AS
4490                        // IDENTITY ( … )`: pg_dump's spelling for
4491                        // identity columns. Same auto-increment
4492                        // lowering as the nextval default; the
4493                        // sequence options inside the parens are
4494                        // no-ops under SPG's max+1 semantics.
4495                        let is_generated = matches!(
4496                            self.tokens.get(self.pos + 1),
4497                            Some(Token::Ident(g)) if g.eq_ignore_ascii_case("generated")
4498                        );
4499                        if !is_generated {
4500                            return Err(self.err(alloc::format!(
4501                                "expected GENERATED after ALTER COLUMN {col_name} ADD, got {:?}",
4502                                self.tokens.get(self.pos + 1)
4503                            )));
4504                        }
4505                        let seq_name = self.scan_sequence_name_until_boundary();
4506                        return Ok(alloc::vec![
4507                            crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4508                                column: col_name,
4509                                seq_name,
4510                            }
4511                        ]);
4512                    }
4513                    other => {
4514                        return Err(self.err(alloc::format!(
4515                            "expected TYPE / SET / DROP / ADD after ALTER COLUMN <name>, got {other:?}"
4516                        )));
4517                    }
4518                }
4519                let new_type = self.parse_column_type_name()?;
4520                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4521                {
4522                    self.advance();
4523                    Some(self.parse_expr(0)?)
4524                } else {
4525                    None
4526                };
4527                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4528                    column: col_name,
4529                    new_type,
4530                    using,
4531                }])
4532            }
4533            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4534            // PG also supports `RENAME TO new_table` for table-name
4535            // rename; that surface is deferred (pg_dump never emits
4536            // it). If the first post-RENAME ident is `TO`, the user
4537            // is asking for table rename — error with a clear
4538            // message rather than misparsing `TO` as a column name.
4539            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4540                self.advance();
4541                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4542                // table-name rename (mailrs round-10 A.5 — used
4543                // by migrate-042's `RENAME TO email_contacts`).
4544                // `TO` lexes as Token::To.
4545                if matches!(self.peek(), Token::To)
4546                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4547                {
4548                    self.advance();
4549                    let new = self.expect_ident_like()?;
4550                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4551                        new,
4552                    }]);
4553                }
4554                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4555                    self.advance();
4556                }
4557                let old = self.expect_ident_like()?;
4558                // `TO` is a reserved keyword token; accept both
4559                // Token::To and Token::Ident("to") for consistency.
4560                if matches!(self.peek(), Token::To) {
4561                    self.advance();
4562                } else {
4563                    self.expect_keyword_ident("to")?;
4564                }
4565                let new = self.expect_ident_like()?;
4566                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4567                    old,
4568                    new,
4569                }])
4570            }
4571            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4572            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4573            // every data block with these. Real disable semantics —
4574            // not no-op — because reload correctness assumes the
4575            // triggers don't fire (rows already carry their
4576            // computed values from prod).
4577            Token::Ident(s)
4578                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4579            {
4580                let enabled = s.eq_ignore_ascii_case("enable");
4581                self.advance();
4582                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4583                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4584                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4585                // pg_dump output) — anything else falls through to
4586                // the catch-all error below.
4587                // v7.22 (round-13 T3) — mysqldump wraps every data
4588                // section in `/*!40000 ALTER TABLE t DISABLE KEYS */`
4589                // + ENABLE KEYS (a MyISAM index-rebuild hint). SPG
4590                // maintains indexes incrementally — engine no-op.
4591                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("keys")) {
4592                    self.advance();
4593                    return Ok(Vec::new());
4594                }
4595                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4596                    return Err(self.err(alloc::format!(
4597                        "expected TRIGGER after {}, got {:?}",
4598                        if enabled { "ENABLE" } else { "DISABLE" },
4599                        self.peek()
4600                    )));
4601                }
4602                self.advance();
4603                // `ALL` lexes as Token::All (reserved); also
4604                // accept Token::Ident("all") for symmetry.
4605                let which = if matches!(self.peek(), Token::All)
4606                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4607                {
4608                    self.advance();
4609                    crate::ast::TriggerSelector::All
4610                } else {
4611                    let name = self.expect_ident_like()?;
4612                    crate::ast::TriggerSelector::Named(name)
4613                };
4614                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4615                    which,
4616                    enabled,
4617                }])
4618            }
4619            other => Err(self.err(alloc::format!(
4620                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4621            ))),
4622        }
4623    }
4624
4625    /// v7.16.2 — peek for `information_schema.<tbl>` /
4626    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4627    /// three tokens + return a synthetic table name the engine's
4628    /// SELECT path recognises as a virtual view. Returns `None`
4629    /// when the head doesn't look like a meta-qualified name.
4630    /// Used by `parse_table_ref` to bypass the
4631    /// `expect_ident_like` schema-strip for these specific PG
4632    /// meta schemas (mailrs round-10 A.3).
4633    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4634        // Extract the schema name. Must be a plain ident token.
4635        let schema = match self.tokens.get(self.pos) {
4636            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4637            _ => return None,
4638        };
4639        // Dot.
4640        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4641            return None;
4642        }
4643        // The table-side ident may lex as a reserved keyword
4644        // (e.g. `Token::Tables`). Tolerate the common ones via a
4645        // helper that reads the trailing token's underlying name.
4646        let tbl = match self.tokens.get(self.pos + 2)? {
4647            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4648            Token::Tables => "tables".to_string(),
4649            // Other PG meta table names that may collide with
4650            // reserved keywords land here as needed.
4651            _ => return None,
4652        };
4653        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4654        // names so the synthetic name doesn't double-prefix
4655        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4656        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4657            ("__spg_info_", tbl.to_ascii_lowercase())
4658        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4659            let bare = tbl
4660                .to_ascii_lowercase()
4661                .strip_prefix("pg_")
4662                .map(alloc::string::String::from)
4663                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4664            ("__spg_pg_", bare)
4665        } else if schema.eq_ignore_ascii_case("mysql") {
4666            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4667            // (`mysql.user`, `mysql.db`). Same synthetic-name
4668            // shape as pg_catalog.
4669            ("__spg_mysql_", tbl.to_ascii_lowercase())
4670        } else {
4671            return None;
4672        };
4673        self.advance(); // schema
4674        self.advance(); // dot
4675        self.advance(); // tbl
4676        Some(alloc::format!("{prefix}{normalised}"))
4677    }
4678
4679    /// Unqualified PG meta-table names (`FROM pg_extension`, `FROM
4680    /// pg_class`) resolve the same way: PG puts `pg_catalog` at the
4681    /// implicit front of every search_path, so a bare reference to a
4682    /// known catalog table always means the catalog table. Only the
4683    /// names the engine actually synthesises are recognised — any
4684    /// other `pg_*` ident stays a user table (mailrs embed round-12).
4685    fn try_peek_meta_bare(&mut self) -> Option<String> {
4686        const PG_META_TABLES: &[&str] = &[
4687            "pg_attribute",
4688            "pg_class",
4689            "pg_constraint",
4690            "pg_database",
4691            "pg_extension",
4692            "pg_index",
4693            "pg_indexes",
4694            "pg_matviews",
4695            "pg_namespace",
4696            "pg_proc",
4697            "pg_roles",
4698            "pg_settings",
4699            "pg_trigger",
4700            "pg_type",
4701            "pg_user",
4702            "pg_views",
4703        ];
4704        let name = match self.tokens.get(self.pos) {
4705            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4706            _ => return None,
4707        };
4708        // A following dot means this ident is a schema qualifier,
4709        // not a table name — let the qualified path handle it.
4710        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4711            return None;
4712        }
4713        if !PG_META_TABLES.contains(&name.as_str()) {
4714            return None;
4715        }
4716        self.advance();
4717        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4718        Some(alloc::format!("__spg_pg_{bare}"))
4719    }
4720
4721    /// Consume a bare ident if its lowercase matches `kw`, else err.
4722    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4723        match self.advance() {
4724            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4725            other => Err(ParseError {
4726                message: format!("expected {kw:?}, got {other:?}"),
4727                token_pos: self.pos.saturating_sub(1),
4728            }),
4729        }
4730    }
4731
4732    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4733    /// literal (`'foo'`) — same shape used by CREATE USER for the
4734    /// username slot.
4735    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4736        match self.advance() {
4737            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4738            other => Err(ParseError {
4739                message: format!("expected identifier or string, got {other:?}"),
4740                token_pos: self.pos.saturating_sub(1),
4741            }),
4742        }
4743    }
4744
4745    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4746        match self.advance() {
4747            Token::String(s) => Ok(s),
4748            other => Err(ParseError {
4749                message: format!("expected quoted string, got {other:?}"),
4750                token_pos: self.pos.saturating_sub(1),
4751            }),
4752        }
4753    }
4754
4755    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4756        // v7.30.2 (mailrs round-25 ask 2) — derived tables /
4757        // subqueries recurse through here without passing
4758        // parse_expr; share the same nesting budget.
4759        self.enter_nested()?;
4760        let r = self.parse_select_stmt_inner();
4761        self.nest_depth -= 1;
4762        r
4763    }
4764
4765    fn parse_select_stmt_inner(&mut self) -> Result<Statement, ParseError> {
4766        // Caller dispatches on Token::Select; the inner helper handles
4767        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4768        // get a fresh bare-select parse and may not have their own ORDER
4769        // BY / LIMIT.
4770        let mut head = self.parse_bare_select()?;
4771        while matches!(self.peek(), Token::Union) {
4772            self.advance();
4773            let kind = if matches!(self.peek(), Token::All) {
4774                self.advance();
4775                UnionKind::All
4776            } else {
4777                UnionKind::Distinct
4778            };
4779            let peer = self.parse_bare_select()?;
4780            head.unions.push((kind, peer));
4781        }
4782        head.order_by = if matches!(self.peek(), Token::Order) {
4783            self.advance();
4784            if !matches!(self.peek(), Token::By) {
4785                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4786            }
4787            self.advance();
4788            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4789            // `<expr> [ASC|DESC]` items.
4790            let mut keys = Vec::new();
4791            loop {
4792                let expr = self.parse_expr(0)?;
4793                let desc = if matches!(self.peek(), Token::Desc) {
4794                    self.advance();
4795                    true
4796                } else if matches!(self.peek(), Token::Asc) {
4797                    self.advance();
4798                    false
4799                } else {
4800                    false
4801                };
4802                // v7.24 (round-16 A) — explicit NULLS FIRST/LAST.
4803                let nulls_first = self.parse_optional_nulls_placement()?;
4804                keys.push(OrderBy {
4805                    expr,
4806                    desc,
4807                    nulls_first,
4808                });
4809                if matches!(self.peek(), Token::Comma) {
4810                    self.advance();
4811                } else {
4812                    break;
4813                }
4814            }
4815            keys
4816        } else {
4817            Vec::new()
4818        };
4819        head.limit = if matches!(self.peek(), Token::Limit) {
4820            self.advance();
4821            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4822            // PG synonyms for "no limit". Treat both as None
4823            // (no head.limit set) so the engine's existing
4824            // unlimited-result path takes over. Reject was the
4825            // pre-5.1 behaviour and broke pg_dump-flavoured
4826            // tooling that occasionally emits LIMIT NULL.
4827            if self.consume_limit_unbounded_sentinel() {
4828                None
4829            } else {
4830                Some(self.parse_limit_expr("LIMIT")?)
4831            }
4832        } else {
4833            None
4834        };
4835        head.offset = if matches!(self.peek(), Token::Offset) {
4836            self.advance();
4837            // PG also accepts an optional `ROW` / `ROWS` trailer
4838            // after the offset value (`OFFSET 10 ROWS`). The
4839            // FETCH-FIRST branch below relies on the same.
4840            let off = self.parse_limit_expr("OFFSET")?;
4841            self.consume_optional_rows_keyword();
4842            Some(off)
4843        } else {
4844            None
4845        };
4846        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4847        // the SQL-standard alias for LIMIT. PG accepts both
4848        // spellings interchangeably; pg_dump emits FETCH FIRST in
4849        // newer versions. We map it onto `head.limit` so the
4850        // engine path is unified.
4851        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4852        {
4853            self.advance(); // FETCH
4854            // `FIRST` or `NEXT` (both legal per SQL standard).
4855            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4856                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4857            {
4858                self.advance();
4859            }
4860            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4861            // implicit 1 — but we always consume one if present).
4862            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4863                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4864            {
4865                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4866                crate::ast::LimitExpr::Literal(1)
4867            } else {
4868                self.parse_limit_expr("FETCH FIRST")?
4869            };
4870            // Eat `ROW` / `ROWS` if not already consumed above.
4871            self.consume_optional_rows_keyword();
4872            // Optional `ONLY` (the spec form) — or the SQL:2008
4873            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4874            // now honours WITH TIES by extending past the LIMIT
4875            // truncation point through every row that shares the
4876            // last-kept row's ORDER BY key.
4877            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4878                if s.eq_ignore_ascii_case("only"))
4879            {
4880                self.advance();
4881            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4882                if s.eq_ignore_ascii_case("with"))
4883            {
4884                self.advance(); // WITH
4885                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4886                    if s.eq_ignore_ascii_case("ties"))
4887                {
4888                    self.advance();
4889                    head.limit_with_ties = true;
4890                }
4891            }
4892            head.limit = Some(count);
4893        }
4894        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4895        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4896        //       [ OF table_name [, …] ]
4897        //       [ NOWAIT | SKIP LOCKED ]
4898        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4899        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4900        // SELECT already returns a consistent snapshot — so these
4901        // are accept-and-discard: the parser absorbs them so
4902        // mailrs / Rails / Django code paths that emit `SELECT
4903        // … FOR UPDATE` for advisory pessimistic locking load
4904        // without a parser error. The on-disk locking model is
4905        // unchanged; callers that rely on FOR UPDATE for read-
4906        // through-write ordering still get the right answer
4907        // because SPG serialises writes anyway.
4908        self.consume_optional_for_lock_clauses();
4909        Ok(Statement::Select(head))
4910    }
4911
4912    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4913    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4914    /// LOCKED ]` trailers. Each clause is fully accepted and
4915    /// discarded — SPG's single-writer model already satisfies the
4916    /// callers' implicit ordering requirement. Stops at the first
4917    /// token that isn't `FOR`.
4918    fn consume_optional_for_lock_clauses(&mut self) {
4919        while matches!(self.peek(), Token::For) {
4920            self.advance(); // FOR
4921            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4922            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4923            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4924                if s.eq_ignore_ascii_case("no"))
4925            {
4926                self.advance(); // NO
4927                // The next ident should be KEY but be generous;
4928                // anything followed by UPDATE/SHARE is accepted.
4929                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4930                    if s.eq_ignore_ascii_case("key"))
4931                {
4932                    self.advance(); // KEY
4933                }
4934            }
4935            // `KEY` prefix (PG `FOR KEY SHARE`).
4936            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4937                if s.eq_ignore_ascii_case("key"))
4938            {
4939                self.advance(); // KEY
4940            }
4941            // Lock-strength keyword: UPDATE / SHARE. Required, but
4942            // we're lenient — an unexpected token here just bails
4943            // (we already consumed FOR; caller's downstream
4944            // dispatch will error if anything actually depends on
4945            // the trailing tokens).
4946            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4947                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4948            {
4949                self.advance();
4950            } else {
4951                // FOR by itself (or `FOR KEY` with nothing after) —
4952                // give up on the lock-clause path. We've already
4953                // advanced past FOR; further attempts to parse
4954                // here would clobber state.
4955                return;
4956            }
4957            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4958            // joining and locking only a subset of tables.
4959            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4960                if s.eq_ignore_ascii_case("of"))
4961            {
4962                self.advance(); // OF
4963                #[allow(clippy::while_let_loop)]
4964                loop {
4965                    match self.peek() {
4966                        Token::Ident(_) | Token::QuotedIdent(_) => {
4967                            self.advance();
4968                            // Optional schema-qualified `schema.table`.
4969                            if matches!(self.peek(), Token::Dot) {
4970                                self.advance();
4971                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4972                                    self.advance();
4973                                }
4974                            }
4975                        }
4976                        _ => break,
4977                    }
4978                    if matches!(self.peek(), Token::Comma) {
4979                        self.advance();
4980                    } else {
4981                        break;
4982                    }
4983                }
4984            }
4985            // Optional `NOWAIT` | `SKIP LOCKED`.
4986            match self.peek().clone() {
4987                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4988                    self.advance();
4989                }
4990                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4991                    self.advance(); // SKIP
4992                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4993                        if s.eq_ignore_ascii_case("locked"))
4994                    {
4995                        self.advance(); // LOCKED
4996                    }
4997                }
4998                _ => {}
4999            }
5000            // Loop: PG allows multiple FOR clauses chained.
5001        }
5002    }
5003
5004    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
5005    /// Bind value gets resolved during prepared-statement Execute;
5006    /// the Pratt expression parser would over-accept here (e.g.
5007    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
5008    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
5009    /// sentinel tokens (PG synonyms for "no limit"). Returns true
5010    /// when one was consumed; caller skips the regular
5011    /// limit-value parse and leaves `head.limit` at None.
5012    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
5013        if matches!(self.peek(), Token::Null) {
5014            self.advance();
5015            return true;
5016        }
5017        if matches!(self.peek(), Token::All) {
5018            self.advance();
5019            return true;
5020        }
5021        false
5022    }
5023
5024    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
5025    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
5026    /// SQL-standard shape. No-op when missing.
5027    fn consume_optional_rows_keyword(&mut self) {
5028        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
5029            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
5030        {
5031            self.advance();
5032        }
5033    }
5034
5035    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
5036        match self.advance() {
5037            Token::Integer(n) if n >= 0 => u32::try_from(n)
5038                .map(crate::ast::LimitExpr::Literal)
5039                .map_err(|_| ParseError {
5040                    message: alloc::format!("{label} value too large: {n}"),
5041                    token_pos: self.pos.saturating_sub(1),
5042                }),
5043            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
5044            other => Err(ParseError {
5045                message: alloc::format!(
5046                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
5047                ),
5048                token_pos: self.pos.saturating_sub(1),
5049            }),
5050        }
5051    }
5052
5053    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
5054    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
5055    /// `unions` empty and `order_by` / `limit` `None`; the top-level
5056    /// `parse_select_stmt` is responsible for filling those in.
5057    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
5058        if !matches!(self.peek(), Token::Select) {
5059            return Err(self.err(format!(
5060                "expected SELECT to start a query block, got {:?}",
5061                self.peek()
5062            )));
5063        }
5064        self.advance();
5065        let distinct = if matches!(self.peek(), Token::Distinct) {
5066            self.advance();
5067            true
5068        } else {
5069            false
5070        };
5071        let items = self.parse_select_list()?;
5072        let from = if matches!(self.peek(), Token::From) {
5073            self.advance();
5074            Some(self.parse_from_clause()?)
5075        } else {
5076            None
5077        };
5078        let where_ = if matches!(self.peek(), Token::Where) {
5079            self.advance();
5080            Some(self.parse_expr(0)?)
5081        } else {
5082            None
5083        };
5084        let mut group_by_all = false;
5085        let group_by = if matches!(self.peek(), Token::Group) {
5086            self.advance();
5087            if !matches!(self.peek(), Token::By) {
5088                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
5089            }
5090            self.advance();
5091            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
5092            // every non-aggregate SELECT-list item later.
5093            if matches!(self.peek(), Token::All) {
5094                self.advance();
5095                group_by_all = true;
5096                None
5097            } else {
5098                let mut groups = Vec::new();
5099                loop {
5100                    groups.push(self.parse_expr(0)?);
5101                    if matches!(self.peek(), Token::Comma) {
5102                        self.advance();
5103                    } else {
5104                        break;
5105                    }
5106                }
5107                Some(groups)
5108            }
5109        } else {
5110            None
5111        };
5112        let having = if matches!(self.peek(), Token::Having) {
5113            self.advance();
5114            Some(self.parse_expr(0)?)
5115        } else {
5116            None
5117        };
5118        Ok(SelectStatement {
5119            ctes: Vec::new(),
5120            distinct,
5121            items,
5122            from,
5123            where_,
5124            group_by,
5125            group_by_all,
5126            having,
5127            unions: Vec::new(),
5128            order_by: Vec::new(),
5129            limit: None,
5130            offset: None,
5131            limit_with_ties: false,
5132        })
5133    }
5134
5135    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
5136        // Caller already consumed CREATE; we're sitting on TABLE.
5137        debug_assert!(matches!(self.peek(), Token::Table));
5138        self.advance();
5139        let if_not_exists = self.consume_if_not_exists();
5140        let name = self.expect_ident_like()?;
5141        // v7.37.6-B — `CREATE TABLE c PARTITION OF parent <bounds>`
5142        // child shape has no column list; the child inherits its
5143        // columns from the parent at engine-DDL time. Detect it
5144        // before the `(` requirement below.
5145        if matches!(self.peek(), Token::Partition)
5146            && Self::tokens_match_ident_ci(self.tokens.get(self.pos + 1), "of")
5147        {
5148            self.advance(); // PARTITION
5149            self.advance(); // of
5150            let partition_of = self.parse_partition_of_tail()?;
5151            return Ok(Statement::CreateTable(CreateTableStatement {
5152                name,
5153                columns: Vec::new(),
5154                if_not_exists,
5155                foreign_keys: Vec::new(),
5156                table_constraints: Vec::new(),
5157                partition_by: None,
5158                partition_of: Some(partition_of),
5159            }));
5160        }
5161        if !matches!(self.peek(), Token::LParen) {
5162            return Err(self.err(format!(
5163                "expected '(' after table name, got {:?}",
5164                self.peek()
5165            )));
5166        }
5167        self.advance();
5168        let mut columns = Vec::new();
5169        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
5170        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
5171        loop {
5172            // v7.6.0 / v7.9.18 — distinguish table-level constraint
5173            // clauses from column definitions. Constraints start
5174            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
5175            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
5176            // a column.
5177            if self.peek_table_level_pk_start() {
5178                table_constraints.push(self.parse_table_level_primary_key()?);
5179            } else if self.peek_table_level_unique_start() {
5180                table_constraints.push(self.parse_table_level_unique()?);
5181            } else if self.peek_table_level_check_start() {
5182                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
5183                table_constraints.push(self.parse_table_level_check()?);
5184            } else if self.peek_mysql_inline_key_start() {
5185                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
5186                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
5187                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
5188                // inside the column list. Skip name + paren list;
5189                // for UNIQUE KEY, register as a UC.
5190                if let Some(uc) = self.parse_mysql_inline_key()? {
5191                    table_constraints.push(uc);
5192                }
5193            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
5194                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
5195                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
5196                // CHECK is named, and the named-CONSTRAINT arm used
5197                // to accept FOREIGN KEY only. The name is accepted
5198                // and discarded — same handling as every other SPG
5199                // constraint name.
5200                self.advance(); // CONSTRAINT
5201                let _name = self.expect_ident_like()?;
5202                table_constraints.push(match kind {
5203                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
5204                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
5205                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
5206                });
5207            } else if self.peek_constraint_or_fk_start() {
5208                foreign_keys.push(self.parse_table_level_fk()?);
5209            } else {
5210                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
5211                // v7.13.0 — fold inline UNIQUE / CHECK column
5212                // constraints into table-level entries so the
5213                // engine path stays uniform.
5214                if col.is_unique {
5215                    table_constraints.push(crate::ast::TableConstraint::Unique {
5216                        name: None,
5217                        columns: alloc::vec![col.name.clone()],
5218                        nulls_not_distinct: false,
5219                    });
5220                }
5221                if let Some(check_expr) = col.check.clone() {
5222                    table_constraints.push(crate::ast::TableConstraint::Check {
5223                        name: None,
5224                        expr: check_expr,
5225                    });
5226                }
5227                columns.push(col);
5228                if let Some(fk) = col_level_fk {
5229                    foreign_keys.push(fk);
5230                }
5231            }
5232            match self.peek() {
5233                Token::Comma => {
5234                    self.advance();
5235                }
5236                Token::RParen => {
5237                    self.advance();
5238                    break;
5239                }
5240                other => {
5241                    return Err(
5242                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
5243                    );
5244                }
5245            }
5246        }
5247        if columns.is_empty() {
5248            return Err(self.err("CREATE TABLE requires at least one column".into()));
5249        }
5250        // v7.14.0 — consume MySQL/MariaDB table options after the
5251        // closing `)`. mysqldump emits things like
5252        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
5253        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
5254        // SPG accepts all forms as no-ops (each option is
5255        // `<ident> [=] <ident-or-string>` separated by whitespace).
5256        self.consume_mysql_table_options();
5257        // v7.37.6-B — declarative-partition-parent suffix
5258        // (`PARTITION BY RANGE (key_col)`) sits after the column
5259        // list + MySQL table-options. v7.37.6-B only accepts RANGE
5260        // and locks the key column at one ident; the engine then
5261        // verifies the column type is TIMESTAMPTZ.
5262        let partition_by = if matches!(self.peek(), Token::Partition) {
5263            self.advance(); // PARTITION
5264            if !matches!(self.peek(), Token::By) {
5265                return Err(self.err(format!(
5266                    "expected BY after PARTITION, got {:?}",
5267                    self.peek()
5268                )));
5269            }
5270            self.advance();
5271            Some(self.parse_partition_by_tail()?)
5272        } else {
5273            None
5274        };
5275        Ok(Statement::CreateTable(CreateTableStatement {
5276            name,
5277            columns,
5278            if_not_exists,
5279            foreign_keys,
5280            table_constraints,
5281            partition_by,
5282            partition_of: None,
5283        }))
5284    }
5285
5286    /// v7.37.6-B — case-insensitive ident match helper for the
5287    /// `PARTITION OF` / `MINVALUE` / `MAXVALUE` keywords. They lex
5288    /// as `Token::Ident("of"/"minvalue"/"maxvalue")` because we
5289    /// didn't burn a global keyword slot for each (see the
5290    /// `Token::Partition` doc-comment in `lexer.rs`).
5291    fn tokens_match_ident_ci(t: Option<&Token>, want: &str) -> bool {
5292        matches!(t, Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case(want))
5293    }
5294
5295    /// v7.37.6-B — after `PARTITION BY`, expect `RANGE (key_col [, ...])`.
5296    fn parse_partition_by_tail(&mut self) -> Result<crate::ast::PartitionBySpec, ParseError> {
5297        use crate::ast::{PartitionBySpec, PartitionKindAst};
5298        let kind = match self.peek() {
5299            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("range") => {
5300                self.advance();
5301                PartitionKindAst::Range
5302            }
5303            other => {
5304                return Err(self.err(format!(
5305                    "PARTITION BY: only RANGE is supported at v7.37.6-B, got {other:?}"
5306                )));
5307            }
5308        };
5309        if !matches!(self.peek(), Token::LParen) {
5310            return Err(self.err(format!(
5311                "expected '(' after PARTITION BY RANGE, got {:?}",
5312                self.peek()
5313            )));
5314        }
5315        self.advance();
5316        let mut key_columns = Vec::new();
5317        loop {
5318            key_columns.push(self.expect_ident_like()?);
5319            match self.peek() {
5320                Token::Comma => {
5321                    self.advance();
5322                }
5323                Token::RParen => {
5324                    self.advance();
5325                    break;
5326                }
5327                other => {
5328                    return Err(self.err(format!(
5329                        "expected ',' or ')' in PARTITION BY key list, got {other:?}"
5330                    )));
5331                }
5332            }
5333        }
5334        if key_columns.is_empty() {
5335            return Err(self.err("PARTITION BY RANGE requires at least one key column".to_string()));
5336        }
5337        Ok(PartitionBySpec { kind, key_columns })
5338    }
5339
5340    /// v7.37.6-B — after `PARTITION OF`, expect
5341    ///   <parent> FOR VALUES FROM ( <expr> ) TO ( <expr> )
5342    /// or
5343    ///   <parent> DEFAULT
5344    fn parse_partition_of_tail(&mut self) -> Result<crate::ast::PartitionOfSpec, ParseError> {
5345        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
5346        let parent_name = self.expect_ident_like()?;
5347        // v7.37.6-B rejects an explicit column list — the child
5348        // inherits from the parent. mailrs round-7 taught us that
5349        // CREATE TABLE-side schema reconciliation hides drift, so
5350        // we surface this as a parse error rather than silently
5351        // ignoring user columns.
5352        if matches!(self.peek(), Token::LParen) {
5353            return Err(self.err(
5354                "CREATE TABLE … PARTITION OF parent: explicit column list not supported \
5355                 at v7.37.6-B; the child inherits its columns from the parent"
5356                    .to_string(),
5357            ));
5358        }
5359        let bounds = match self.peek() {
5360            Token::Default => {
5361                self.advance();
5362                PartitionOfBoundsAst::Default
5363            }
5364            Token::For => {
5365                self.advance();
5366                if !matches!(self.peek(), Token::Values) {
5367                    return Err(
5368                        self.err(format!("expected VALUES after FOR, got {:?}", self.peek()))
5369                    );
5370                }
5371                self.advance();
5372                if !matches!(self.peek(), Token::From) {
5373                    return Err(self.err(format!(
5374                        "expected FROM after FOR VALUES, got {:?}",
5375                        self.peek()
5376                    )));
5377                }
5378                self.advance();
5379                let lower = Box::new(self.parse_partition_bound_expr()?);
5380                if !matches!(self.peek(), Token::To) {
5381                    return Err(self.err(format!(
5382                        "expected TO after FROM (...), got {:?}",
5383                        self.peek()
5384                    )));
5385                }
5386                self.advance();
5387                let upper = Box::new(self.parse_partition_bound_expr()?);
5388                PartitionOfBoundsAst::Range { lower, upper }
5389            }
5390            other => {
5391                return Err(self.err(format!(
5392                    "expected FOR VALUES or DEFAULT after PARTITION OF parent, got {other:?}"
5393                )));
5394            }
5395        };
5396        Ok(PartitionOfSpec {
5397            parent_name,
5398            bounds,
5399        })
5400    }
5401
5402    /// v7.37.6-B — a single `( <expr> )` bound. `MINVALUE` /
5403    /// `MAXVALUE` lex as Ident; rewrite them into FunctionCall
5404    /// markers (no-arg builtins) so the engine resolves them
5405    /// against [`spg_storage::PartitionBound::{MinValue, MaxValue}`].
5406    fn parse_partition_bound_expr(&mut self) -> Result<crate::ast::Expr, ParseError> {
5407        if !matches!(self.peek(), Token::LParen) {
5408            return Err(self.err(format!(
5409                "expected '(' before partition bound, got {:?}",
5410                self.peek()
5411            )));
5412        }
5413        self.advance();
5414        let expr = match self.peek() {
5415            Token::Ident(s) | Token::QuotedIdent(s)
5416                if s.eq_ignore_ascii_case("minvalue") || s.eq_ignore_ascii_case("maxvalue") =>
5417            {
5418                let name = s.to_ascii_uppercase();
5419                self.advance();
5420                crate::ast::Expr::FunctionCall {
5421                    name,
5422                    args: Vec::new(),
5423                }
5424            }
5425            _ => self.parse_expr(0)?,
5426        };
5427        if !matches!(self.peek(), Token::RParen) {
5428            return Err(self.err(format!(
5429                "expected ')' after partition bound, got {:?}",
5430                self.peek()
5431            )));
5432        }
5433        self.advance();
5434        Ok(expr)
5435    }
5436
5437    /// v7.14.0 — true when the next tokens look like an inline
5438    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
5439    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
5440    /// — each followed by an optional name + `(...)`. Critical:
5441    /// a column NAMED `key` / `index` (PG accepts as ident) must
5442    /// NOT be mistaken for the KEY constraint shape. We disambig
5443    /// by requiring the keyword to be followed by either `(` or
5444    /// `<ident> (`.
5445    fn peek_mysql_inline_key_start(&self) -> bool {
5446        let cur = self.peek();
5447        // Shapes:
5448        //   KEY (cols)
5449        //   KEY name (cols)
5450        //   INDEX (cols)
5451        //   INDEX name (cols)
5452        //   UNIQUE KEY [name] (cols)
5453        //   UNIQUE INDEX [name] (cols)
5454        //   FULLTEXT [KEY|INDEX] [name] (cols)
5455        //   SPATIAL [KEY|INDEX] [name] (cols)
5456        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
5457            // tokens at skip = the position AFTER the index-form
5458            // keywords (KEY/INDEX) have been consumed.
5459            match self.tokens.get(skip) {
5460                Some(Token::LParen) => true,
5461                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
5462                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
5463                }
5464                _ => false,
5465            }
5466        };
5467        // `INDEX` lexes as Token::Index (reserved), not as
5468        // Token::Ident("index"). Both shapes count as a KEY/INDEX
5469        // start; the peek helper below handles either.
5470        let is_key_or_index_tok = |t: &Token| -> bool {
5471            matches!(t, Token::Index)
5472                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
5473        };
5474        match cur {
5475            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
5476            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5477                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
5478            }
5479            Token::Ident(s)
5480                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
5481            {
5482                let nxt = self.tokens.get(self.pos + 1);
5483                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
5484                    self.pos + 2
5485                } else {
5486                    self.pos + 1
5487                };
5488                after_keyword_followed_by_paren_or_ident_paren(after_after)
5489            }
5490            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
5491                let nxt = self.tokens.get(self.pos + 1);
5492                if !nxt.is_some_and(is_key_or_index_tok) {
5493                    return false;
5494                }
5495                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
5496            }
5497            _ => false,
5498        }
5499    }
5500
5501    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
5502    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
5503    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
5504    /// returns Some(TableConstraint::Index) so the engine builds
5505    /// a real BTree index on the leading column (mysqldump
5506    /// `KEY idx_posts_author (author_id)` shape).
5507    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
5508    /// (the storage layer has no matching AM).
5509    fn parse_mysql_inline_key(
5510        &mut self,
5511    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
5512        // Detect UNIQUE prefix.
5513        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
5514        {
5515            self.advance();
5516            true
5517        } else {
5518            false
5519        };
5520        // Consume FULLTEXT / SPATIAL prefix and record which one
5521        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
5522        // dedicated TableConstraint variant so the engine can
5523        // build a tsvector-GIN; SPATIAL still has no matching
5524        // AM, so it falls back to accept-as-no-op.
5525        let mut is_fulltext = false;
5526        let mut is_spatial = false;
5527        if let Token::Ident(s) = self.peek().clone() {
5528            if s.eq_ignore_ascii_case("fulltext") {
5529                self.advance();
5530                is_fulltext = true;
5531            } else if s.eq_ignore_ascii_case("spatial") {
5532                self.advance();
5533                is_spatial = true;
5534            }
5535        }
5536        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
5537        // (reserved); accept either token shape.
5538        match self.peek() {
5539            Token::Index => {
5540                self.advance();
5541            }
5542            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5543                self.advance();
5544            }
5545            other => {
5546                return Err(self.err(alloc::format!(
5547                    "expected KEY/INDEX in inline index declaration, got {other:?}"
5548                )));
5549            }
5550        }
5551        // Optional index name (an ident before the `(`).
5552        // v7.15.0 — capture the name when present so the engine
5553        // builds the secondary index under the user's chosen
5554        // name (matches mysqldump's `KEY idx_x (col)` shape).
5555        let mut idx_name: Option<String> = None;
5556        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
5557            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
5558        {
5559            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
5560                idx_name = Some(s);
5561            }
5562        }
5563        // Optional `USING BTREE` / `USING HASH` (MySQL).
5564        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5565            self.advance();
5566            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5567                self.advance();
5568            }
5569        }
5570        // Required column list `(col [, col]*)`.
5571        if !matches!(self.peek(), Token::LParen) {
5572            return Err(self.err(alloc::format!(
5573                "expected '(' in inline KEY/INDEX, got {:?}",
5574                self.peek()
5575            )));
5576        }
5577        self.advance();
5578        let mut cols: Vec<String> = Vec::new();
5579        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5580            self.advance();
5581            cols.push(s);
5582            // Skip optional `(length)` per-column prefix.
5583            if matches!(self.peek(), Token::LParen) {
5584                let mut depth = 1usize;
5585                self.advance();
5586                while depth > 0 {
5587                    match self.peek() {
5588                        Token::LParen => depth += 1,
5589                        Token::RParen => depth -= 1,
5590                        Token::Eof => break,
5591                        _ => {}
5592                    }
5593                    self.advance();
5594                }
5595            }
5596            // Skip optional ASC / DESC.
5597            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5598                || matches!(self.peek(), Token::Asc | Token::Desc)
5599            {
5600                self.advance();
5601            }
5602            if matches!(self.peek(), Token::Comma) {
5603                self.advance();
5604                continue;
5605            }
5606            break;
5607        }
5608        if matches!(self.peek(), Token::RParen) {
5609            self.advance();
5610        }
5611        // Trailing options on the inline index — comment / etc.
5612        // Skip until comma or `)`.
5613        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5614            self.advance();
5615        }
5616        if cols.is_empty() {
5617            return Ok(None);
5618        }
5619        if is_unique {
5620            // Carry the captured idx_name on UNIQUE too so future
5621            // engine work can name the underlying BTree
5622            // accordingly; today the unique-constraint installer
5623            // synthesises the name itself, but Display round-trip
5624            // benefits from preserving it.
5625            Ok(Some(crate::ast::TableConstraint::Unique {
5626                name: idx_name,
5627                columns: cols,
5628                nulls_not_distinct: false,
5629            }))
5630        } else if is_fulltext {
5631            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5632            // routes through `TableConstraint::FulltextIndex`;
5633            // the engine builds a tsvector-GIN over each named
5634            // column so MATCH AGAINST gets a real inverted
5635            // index instead of a silently-dropped declaration.
5636            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5637                name: idx_name,
5638                columns: cols,
5639            }))
5640        } else if is_spatial {
5641            // SPG has no native SPATIAL AM. Accept-as-no-op
5642            // (declaration is parsed, but no index is built).
5643            Ok(None)
5644        } else {
5645            // v7.15.0 — plain KEY / INDEX builds a real BTree
5646            // secondary index.
5647            Ok(Some(crate::ast::TableConstraint::Index {
5648                name: idx_name,
5649                columns: cols,
5650            }))
5651        }
5652    }
5653
5654    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5655    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5656    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5657    /// (in any order, separated by whitespace).
5658    fn consume_mysql_table_options(&mut self) {
5659        loop {
5660            // Heuristic: a table option is an ident (or `DEFAULT`
5661            // reserved keyword) followed by `=` and an
5662            // ident / string / integer.
5663            let name_lc = match self.peek().clone() {
5664                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5665                Token::Default => alloc::string::String::from("default"),
5666                _ => break,
5667            };
5668            let known = matches!(
5669                name_lc.as_str(),
5670                "engine"
5671                    | "default"
5672                    | "charset"
5673                    | "collate"
5674                    | "auto_increment"
5675                    | "row_format"
5676                    | "comment"
5677                    | "pack_keys"
5678                    | "stats_persistent"
5679                    | "stats_auto_recalc"
5680                    | "stats_sample_pages"
5681                    | "key_block_size"
5682                    | "tablespace"
5683                    | "min_rows"
5684                    | "max_rows"
5685                    | "checksum"
5686                    | "delay_key_write"
5687                    | "insert_method"
5688                    | "data"
5689                    | "index"
5690                    | "encryption"
5691                    | "compression"
5692            );
5693            if !known {
5694                break;
5695            }
5696            self.advance(); // option name
5697            // `DEFAULT` optional prefix is followed by `CHARSET` /
5698            // `COLLATE`; consume the next ident too.
5699            if name_lc == "default" {
5700                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5701                    self.advance();
5702                }
5703            }
5704            if matches!(self.peek(), Token::Eq) {
5705                self.advance();
5706            }
5707            match self.peek() {
5708                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5709                    self.advance();
5710                }
5711                _ => {}
5712            }
5713        }
5714    }
5715
5716    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5717    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5718    /// sure (otherwise a column literally named `primary` would
5719    /// be mistaken).
5720    fn peek_table_level_pk_start(&self) -> bool {
5721        let cur = self.peek();
5722        let nxt = self.tokens.get(self.pos + 1);
5723        let nxt2 = self.tokens.get(self.pos + 2);
5724        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5725        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5726        let is_lparen = matches!(nxt2, Some(Token::LParen));
5727        is_primary && is_key && is_lparen
5728    }
5729
5730    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5731    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5732    /// (mailrs round-5 G10).
5733    fn peek_table_level_unique_start(&self) -> bool {
5734        let cur = self.peek();
5735        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5736        if !is_unique {
5737            return false;
5738        }
5739        let n1 = self.tokens.get(self.pos + 1);
5740        // Plain `UNIQUE (…)`.
5741        if matches!(n1, Some(Token::LParen)) {
5742            return true;
5743        }
5744        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5745        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5746        if !is_nulls {
5747            return false;
5748        }
5749        let n2 = self.tokens.get(self.pos + 2);
5750        let n3 = self.tokens.get(self.pos + 3);
5751        let n4 = self.tokens.get(self.pos + 4);
5752        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5753        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5754            return true;
5755        }
5756        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5757        if matches!(n2, Some(Token::Not))
5758            && matches!(n3, Some(Token::Distinct))
5759            && matches!(n4, Some(Token::LParen))
5760        {
5761            return true;
5762        }
5763        false
5764    }
5765
5766    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5767        self.advance(); // PRIMARY
5768        self.advance(); // KEY
5769        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5770        Ok(crate::ast::TableConstraint::PrimaryKey {
5771            name: None,
5772            columns,
5773        })
5774    }
5775
5776    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5777        self.advance(); // UNIQUE
5778        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5779        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5780        // is `NULLS DISTINCT` per the SQL standard.
5781        let mut nulls_not_distinct = false;
5782        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5783            let n1 = self.tokens.get(self.pos + 1);
5784            let n2 = self.tokens.get(self.pos + 2);
5785            let is_not = matches!(n1, Some(Token::Not));
5786            let is_distinct = matches!(n2, Some(Token::Distinct));
5787            if is_not && is_distinct {
5788                self.advance(); // NULLS
5789                self.advance(); // NOT
5790                self.advance(); // DISTINCT
5791                nulls_not_distinct = true;
5792            } else if matches!(n1, Some(Token::Distinct)) {
5793                self.advance(); // NULLS
5794                self.advance(); // DISTINCT
5795            }
5796        }
5797        let columns = self.parse_paren_ident_list("UNIQUE")?;
5798        Ok(crate::ast::TableConstraint::Unique {
5799            name: None,
5800            columns,
5801            nulls_not_distinct,
5802        })
5803    }
5804
5805    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5806    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5807    /// expression.
5808    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5809        self.advance(); // CHECK
5810        if !matches!(self.peek(), Token::LParen) {
5811            return Err(self.err(alloc::format!(
5812                "expected '(' after CHECK, got {:?}",
5813                self.peek()
5814            )));
5815        }
5816        self.advance();
5817        let expr = self.parse_expr(0)?;
5818        if !matches!(self.peek(), Token::RParen) {
5819            return Err(self.err(alloc::format!(
5820                "expected ')' to close CHECK predicate, got {:?}",
5821                self.peek()
5822            )));
5823        }
5824        self.advance();
5825        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5826    }
5827
5828    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5829    fn peek_table_level_check_start(&self) -> bool {
5830        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5831    }
5832
5833    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5834    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5835    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5836    /// own CONSTRAINT prefix).
5837    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5838        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5839            return None;
5840        }
5841        // tokens[pos+1] is the constraint name (any ident-like);
5842        // tokens[pos+2] is the kind keyword.
5843        match self.tokens.get(self.pos + 2) {
5844            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5845                Some(NamedTableConstraintKind::Check)
5846            }
5847            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5848                Some(NamedTableConstraintKind::Unique)
5849            }
5850            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5851                Some(NamedTableConstraintKind::PrimaryKey)
5852            }
5853            _ => None,
5854        }
5855    }
5856
5857    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5858        if !matches!(self.peek(), Token::LParen) {
5859            return Err(self.err(alloc::format!(
5860                "expected '(' after {ctx}, got {:?}",
5861                self.peek()
5862            )));
5863        }
5864        self.advance();
5865        let mut out = Vec::new();
5866        loop {
5867            out.push(self.expect_ident_like()?);
5868            match self.peek() {
5869                Token::Comma => {
5870                    self.advance();
5871                }
5872                Token::RParen => {
5873                    self.advance();
5874                    break;
5875                }
5876                other => {
5877                    return Err(self.err(alloc::format!(
5878                        "expected ',' or ')' in {ctx} list, got {other:?}"
5879                    )));
5880                }
5881            }
5882        }
5883        if out.is_empty() {
5884            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5885        }
5886        Ok(out)
5887    }
5888
5889    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5890    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5891    /// table-level FK; a column def never starts with either keyword
5892    /// (column names are not in this reserved set).
5893    fn peek_constraint_or_fk_start(&self) -> bool {
5894        let is_constraint_kw = matches!(
5895            self.peek(),
5896            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5897        );
5898        let is_foreign_kw = matches!(
5899            self.peek(),
5900            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5901        );
5902        is_constraint_kw || is_foreign_kw
5903    }
5904
5905    /// v7.6.0 — parse a table-level FK clause:
5906    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5907    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5908    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5909        let mut name: Option<String> = None;
5910        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5911            self.advance();
5912            name = Some(self.expect_ident_like()?);
5913        }
5914        // `FOREIGN`
5915        match self.advance() {
5916            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5917            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5918        }
5919        // `KEY`
5920        match self.advance() {
5921            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5922            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5923        }
5924        // `(col, col, ...)`
5925        if !matches!(self.peek(), Token::LParen) {
5926            return Err(self.err(format!(
5927                "expected '(' after FOREIGN KEY, got {:?}",
5928                self.peek()
5929            )));
5930        }
5931        self.advance();
5932        let mut columns = Vec::new();
5933        loop {
5934            columns.push(self.expect_ident_like()?);
5935            match self.peek() {
5936                Token::Comma => {
5937                    self.advance();
5938                }
5939                Token::RParen => {
5940                    self.advance();
5941                    break;
5942                }
5943                other => {
5944                    return Err(self.err(format!(
5945                        "expected ',' or ')' in FK column list, got {other:?}"
5946                    )));
5947                }
5948            }
5949        }
5950        if columns.is_empty() {
5951            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5952        }
5953        let (parent_table, parent_columns, on_delete, on_update) =
5954            self.parse_references_tail(columns.len())?;
5955        Ok(ForeignKeyConstraint {
5956            name,
5957            columns,
5958            parent_table,
5959            parent_columns,
5960            on_delete,
5961            on_update,
5962        })
5963    }
5964
5965    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5966    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5967    /// the local column count, used to default the parent column
5968    /// list when omitted (SQL spec: parent's PK is implied).
5969    fn parse_references_tail(
5970        &mut self,
5971        expected_arity: usize,
5972    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5973        match self.advance() {
5974            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5975            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5976        }
5977        let parent_table = self.expect_ident_like()?;
5978        let mut parent_columns: Vec<String> = Vec::new();
5979        if matches!(self.peek(), Token::LParen) {
5980            self.advance();
5981            loop {
5982                parent_columns.push(self.expect_ident_like()?);
5983                match self.peek() {
5984                    Token::Comma => {
5985                        self.advance();
5986                    }
5987                    Token::RParen => {
5988                        self.advance();
5989                        break;
5990                    }
5991                    other => {
5992                        return Err(self.err(format!(
5993                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5994                        )));
5995                    }
5996                }
5997            }
5998        }
5999        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
6000            return Err(self.err(format!(
6001                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
6002                expected_arity,
6003                parent_columns.len()
6004            )));
6005        }
6006        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
6007        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
6008        // <action>` / `ON UPDATE <action>` in either order. PG /
6009        // pg_dump emits the timing clause AFTER the ON clauses
6010        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
6011        // but the SQL spec allows either order. We loop over
6012        // every possible trailer and dispatch on the next token,
6013        // stopping when nothing matches. Phase 3.1 changes the
6014        // bare DEFERRABLE form from hard-error to accept-as-
6015        // immediate; SPG is single-writer with no deferred-
6016        // constraint window so the runtime semantics are always
6017        // immediate even when INITIALLY DEFERRED is requested.
6018        let mut on_delete = FkAction::Restrict;
6019        let mut on_update = FkAction::Restrict;
6020        let mut seen_on_delete = false;
6021        let mut seen_on_update = false;
6022        loop {
6023            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
6024            let before = self.pos;
6025            self.consume_optional_deferrable_clauses()?;
6026            if self.pos != before {
6027                continue;
6028            }
6029            // ON DELETE / ON UPDATE.
6030            if !matches!(self.peek(), Token::On) {
6031                break;
6032            }
6033            self.advance();
6034            let which = self.advance();
6035            let action = self.parse_fk_action()?;
6036            match which {
6037                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
6038                    if seen_on_delete {
6039                        return Err(self.err("ON DELETE specified twice".into()));
6040                    }
6041                    seen_on_delete = true;
6042                    on_delete = action;
6043                }
6044                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
6045                    if seen_on_update {
6046                        return Err(self.err("ON UPDATE specified twice".into()));
6047                    }
6048                    seen_on_update = true;
6049                    on_update = action;
6050                }
6051                other => {
6052                    return Err(
6053                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
6054                    );
6055                }
6056            }
6057        }
6058        Ok((parent_table, parent_columns, on_delete, on_update))
6059    }
6060
6061    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
6062    /// NO ACTION`.
6063    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
6064        match self.advance() {
6065            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
6066            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
6067            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
6068                Token::Null => Ok(FkAction::SetNull),
6069                Token::Default => Ok(FkAction::SetDefault),
6070                other => Err(self.err(format!(
6071                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
6072                ))),
6073            },
6074            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
6075                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
6076                other => Err(self.err(format!(
6077                    "expected ACTION after NO in FK action, got {other:?}"
6078                ))),
6079            },
6080            other => Err(self.err(format!(
6081                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
6082            ))),
6083        }
6084    }
6085
6086    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
6087    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
6088    fn consume_if_not_exists(&mut self) -> bool {
6089        // `IF` arrives as a bare Ident (we don't reserve it because it
6090        // also appears mid-expression in PG, though we don't support
6091        // those forms yet).
6092        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
6093        if !looks_like_if {
6094            return false;
6095        }
6096        // Peek one ahead before committing: only consume IF when it's
6097        // actually `IF NOT EXISTS`.
6098        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
6099            return false;
6100        }
6101        if !matches!(
6102            self.tokens.get(self.pos + 2),
6103            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
6104        ) {
6105            return false;
6106        }
6107        self.advance(); // IF
6108        self.advance(); // NOT
6109        self.advance(); // EXISTS
6110        true
6111    }
6112
6113    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
6114    /// Consumes IF EXISTS as a pair; returns false otherwise
6115    /// without consuming any tokens.
6116    fn consume_if_exists(&mut self) -> bool {
6117        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
6118        if !looks_like_if {
6119            return false;
6120        }
6121        if !matches!(
6122            self.tokens.get(self.pos + 1),
6123            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
6124        ) {
6125            return false;
6126        }
6127        self.advance(); // IF
6128        self.advance(); // EXISTS
6129        true
6130    }
6131
6132    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
6133    /// qualifiers after an index column ref. ASC / DESC are
6134    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
6135    /// We accept and discard them since single-column BTree
6136    /// stores rows in natural key order today.
6137    /// v7.24 (round-16 A) — `NULLS FIRST` / `NULLS LAST` after an
6138    /// ORDER BY key. Returns None when absent.
6139    fn parse_optional_nulls_placement(&mut self) -> Result<Option<bool>, ParseError> {
6140        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
6141            return Ok(None);
6142        }
6143        self.advance();
6144        match self.advance() {
6145            Token::Ident(s) if s.eq_ignore_ascii_case("first") => Ok(Some(true)),
6146            Token::Ident(s) if s.eq_ignore_ascii_case("last") => Ok(Some(false)),
6147            other => Err(self.err(alloc::format!(
6148                "expected FIRST or LAST after NULLS, got {other:?}"
6149            ))),
6150        }
6151    }
6152
6153    fn consume_optional_index_column_qualifiers(&mut self) {
6154        loop {
6155            match self.peek() {
6156                Token::Asc | Token::Desc => {
6157                    self.advance();
6158                }
6159                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
6160                    let look = self.tokens.get(self.pos + 1);
6161                    if matches!(
6162                        look,
6163                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
6164                            || k.eq_ignore_ascii_case("last")
6165                    ) {
6166                        self.advance();
6167                        self.advance();
6168                    } else {
6169                        break;
6170                    }
6171                }
6172                _ => break,
6173            }
6174        }
6175    }
6176
6177    fn parse_create_index_stmt_after_create(
6178        &mut self,
6179        is_unique: bool,
6180    ) -> Result<Statement, ParseError> {
6181        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
6182        debug_assert!(matches!(self.peek(), Token::Index));
6183        self.advance();
6184        let if_not_exists = self.consume_if_not_exists();
6185        let name = self.expect_ident_like()?;
6186        if !matches!(self.peek(), Token::On) {
6187            return Err(self.err(format!(
6188                "expected ON after CREATE INDEX <name>, got {:?}",
6189                self.peek()
6190            )));
6191        }
6192        self.advance();
6193        let table = self.expect_ident_like()?;
6194        // Optional `USING <method>` — only recognised method in v2.0 is
6195        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
6196        // ident `using` (we don't promote it to a reserved keyword
6197        // because it isn't reserved anywhere else in our SQL surface).
6198        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6199            self.advance();
6200            let m = self.expect_ident_like()?;
6201            match m.to_ascii_lowercase().as_str() {
6202                "hnsw" => IndexMethod::Hnsw,
6203                "btree" => IndexMethod::BTree,
6204                "brin" => IndexMethod::Brin,
6205                // v7.12.3 — real GIN inverted index over `tsvector`.
6206                // v7.9.26b's `USING gin` → BTree silent fallback is
6207                // gone; the engine validates that the indexed column
6208                // is `tsvector` at CREATE INDEX time.
6209                "gin" => IndexMethod::Gin,
6210                // v7.9.26b — PG `pg_dump` emits `USING gist` /
6211                // `USING spgist` / `USING hash` for their built-in
6212                // AMs that SPG doesn't have a matching
6213                // implementation for; degrade to BTree on the
6214                // leading column so the schema loads + the index
6215                // catalogue stays consistent. Operator pays the
6216                // planner cost only for the queries that would have
6217                // used the specialised AM.
6218                "gist" | "spgist" | "hash" => IndexMethod::BTree,
6219                // v7.11.3 — pgvector ships both `ivfflat` and
6220                // `hnsw`. Customers shouldn't have to choose
6221                // their on-disk index method based on what SPG
6222                // implements; accept `ivfflat` as a synonym for
6223                // `hnsw` so PG schemas using either method drop
6224                // in. The vector distance op (`<->` / `<#>` /
6225                // `<=>`) at query time still picks the metric.
6226                "ivfflat" => IndexMethod::Hnsw,
6227                other => {
6228                    return Err(self.err(alloc::format!(
6229                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
6230                    )));
6231                }
6232            }
6233        } else {
6234            IndexMethod::BTree
6235        };
6236        if !matches!(self.peek(), Token::LParen) {
6237            return Err(self.err(format!(
6238                "expected '(' before indexed column, got {:?}",
6239                self.peek()
6240            )));
6241        }
6242        self.advance();
6243        // v6.8.2 — accept either a bare column ident (legacy) or
6244        // an expression `fn(col, …)` for expression indexes.
6245        // Distinguish by peeking the token *after* the current
6246        // ident: `ident )` is the legacy column-only path;
6247        // anything else triggers the Pratt expression parser.
6248        // (`advance()` uses `mem::replace` to nil out the current
6249        // slot, so we can't save+rewind cleanly — peek-ahead via
6250        // direct index avoids the mutation.)
6251        let mut opclass: Option<String> = None;
6252        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
6253            // Single column with `)` immediately after — fast path.
6254            // v7.9.29 — also: bare column followed by `,` (the
6255            // multi-column form `(a, b, c)`). Without this branch
6256            // the leading ident gets pulled into `parse_expr`
6257            // which then sets `expression = Some(Column(a))` and
6258            // breaks Display round-trip on the multi-column shape.
6259            Token::Ident(s) | Token::QuotedIdent(s)
6260                if matches!(
6261                    self.tokens.get(self.pos + 1),
6262                    Some(Token::RParen | Token::Comma)
6263                ) =>
6264            {
6265                self.advance();
6266                (s, None)
6267            }
6268            // v7.9.22 — single column followed by a pgvector
6269            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
6270            // v7.15.0 — capture the opclass instead of discarding
6271            // it so the engine can dispatch (e.g. `gin_trgm_ops`
6272            // → real trigram-shingle GIN over a TEXT column).
6273            // Vector/HNSW opclasses still take their distance
6274            // metric from the query operator (`<->` / `<#>` /
6275            // `<=>`), so for those callers the opclass stays
6276            // informational.
6277            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
6278            // opclass: `(embedding public.vector_cosine_ops)`. Strip
6279            // the schema and dispatch on the bare opclass, the same
6280            // treatment table/type names get.
6281            Token::Ident(s) | Token::QuotedIdent(s)
6282                if matches!(
6283                    self.tokens.get(self.pos + 1),
6284                    Some(Token::Ident(_) | Token::QuotedIdent(_))
6285                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
6286                    && matches!(
6287                        self.tokens.get(self.pos + 3),
6288                        Some(Token::Ident(op) | Token::QuotedIdent(op))
6289                            if is_vector_opclass_name(op)
6290                    ) =>
6291            {
6292                self.advance(); // column name
6293                self.advance(); // schema qualifier
6294                self.advance(); // dot
6295                let op_tok = self.advance();
6296                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6297                    opclass = Some(op.to_ascii_lowercase());
6298                }
6299                (s, None)
6300            }
6301            Token::Ident(s) | Token::QuotedIdent(s)
6302                if matches!(
6303                    self.tokens.get(self.pos + 1),
6304                    Some(Token::Ident(op) | Token::QuotedIdent(op))
6305                        if is_vector_opclass_name(op)
6306                ) =>
6307            {
6308                self.advance(); // column name
6309                // Capture the opclass token, lower-cased for
6310                // case-insensitive engine dispatch.
6311                let op_tok = self.advance();
6312                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6313                    opclass = Some(op.to_ascii_lowercase());
6314                }
6315                (s, None)
6316            }
6317            Token::Ident(_) | Token::QuotedIdent(_) => {
6318                let key_expr = self.parse_expr(0)?;
6319                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6320                    self.err("expression index key must reference at least one column".into())
6321                })?;
6322                (primary, Some(key_expr))
6323            }
6324            // v7.37.43-T4 — parenthesised expression index key
6325            // `CREATE INDEX … ON t ((payload->'bundle'->>'id'))`.
6326            // PG's CREATE INDEX requires the expression to be in
6327            // its own parens to disambiguate function calls from
6328            // column lists, so this `LParen` is the inner open-paren
6329            // of an expression key. parse_expr handles the recursive
6330            // descent and consumes the matching `RParen`.
6331            Token::LParen => {
6332                let key_expr = self.parse_expr(0)?;
6333                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6334                    self.err("expression index key must reference at least one column".into())
6335                })?;
6336                (primary, Some(key_expr))
6337            }
6338            other => {
6339                return Err(self.err(format!(
6340                    "expected column ident or expression, got {other:?}"
6341                )));
6342            }
6343        };
6344        // v7.9.14 — accept extra comma-separated columns inside
6345        // the index key parens (`CREATE INDEX … (a, b, c)`).
6346        // mailrs F2. Each extra column may carry an optional
6347        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
6348        // — parsed and discarded; SPG doesn't honour direction
6349        // on a BTree index today (column ordering is intrinsic
6350        // to the storage). v7.10 will widen to genuine composite
6351        // index keys.
6352        let mut extra_columns: Vec<String> = Vec::new();
6353        // The leading column may also have ASC/DESC after it.
6354        self.consume_optional_index_column_qualifiers();
6355        while matches!(self.peek(), Token::Comma) {
6356            self.advance();
6357            let extra = self.expect_ident_like()?;
6358            self.consume_optional_index_column_qualifiers();
6359            extra_columns.push(extra);
6360        }
6361        if !matches!(self.peek(), Token::RParen) {
6362            return Err(self.err(format!(
6363                "expected ')' after indexed column / expression, got {:?}",
6364                self.peek()
6365            )));
6366        }
6367        self.advance();
6368        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
6369        // index-only-scan annotation. Bare ident (not a reserved
6370        // keyword) so we test by case-insensitive string match.
6371        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
6372        {
6373            self.advance();
6374            if !matches!(self.peek(), Token::LParen) {
6375                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
6376            }
6377            self.advance();
6378            let mut cols = Vec::new();
6379            loop {
6380                cols.push(self.expect_ident_like()?);
6381                match self.peek() {
6382                    Token::Comma => {
6383                        self.advance();
6384                    }
6385                    Token::RParen => {
6386                        self.advance();
6387                        break;
6388                    }
6389                    other => {
6390                        return Err(self.err(format!(
6391                            "expected ',' or ')' in INCLUDE list, got {other:?}"
6392                        )));
6393                    }
6394                }
6395            }
6396            cols
6397        } else {
6398            Vec::new()
6399        };
6400        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
6401        // storage parameters. pgvector emits `WITH (lists = N)` for
6402        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
6403        // SPG's HNSW picks its own parameters today (tunable via
6404        // env vars), so the WITH clause is informational and dropped.
6405        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
6406            self.advance();
6407            if !matches!(self.peek(), Token::LParen) {
6408                return Err(self.err(format!(
6409                    "expected '(' after WITH in CREATE INDEX, got {:?}",
6410                    self.peek()
6411                )));
6412            }
6413            self.advance();
6414            loop {
6415                if matches!(self.peek(), Token::RParen) {
6416                    self.advance();
6417                    break;
6418                }
6419                // Drain `key = value` or bare `key` tokens.
6420                let _ = self.advance(); // key
6421                if matches!(self.peek(), Token::Eq) {
6422                    self.advance();
6423                    let _ = self.advance(); // value (int / string / ident)
6424                }
6425                match self.peek() {
6426                    Token::Comma => {
6427                        self.advance();
6428                    }
6429                    Token::RParen => {
6430                        self.advance();
6431                        break;
6432                    }
6433                    other => {
6434                        return Err(self.err(format!(
6435                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
6436                        )));
6437                    }
6438                }
6439            }
6440        }
6441        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
6442        let partial_predicate = if matches!(self.peek(), Token::Where) {
6443            self.advance();
6444            Some(self.parse_expr(0)?)
6445        } else {
6446            None
6447        };
6448        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
6449        // sense: uniqueness over an ANN structure has no clean
6450        // semantics. Reject early. (BRIN UNIQUE is similarly
6451        // meaningless — block both.)
6452        if is_unique && !matches!(method, IndexMethod::BTree) {
6453            return Err(self.err(alloc::format!(
6454                "UNIQUE is only supported on BTree indexes, got USING {:?}",
6455                method
6456            )));
6457        }
6458        Ok(Statement::CreateIndex(CreateIndexStatement {
6459            name,
6460            table,
6461            column,
6462            method,
6463            if_not_exists,
6464            included_columns,
6465            partial_predicate,
6466            extra_columns: extra_columns.clone(),
6467            expression,
6468            is_unique,
6469            opclass,
6470        }))
6471    }
6472
6473    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
6474    /// column-level `REFERENCES ...` clause. The trailing FK is
6475    /// normalised into table-level shape (single-element columns +
6476    /// parent_columns) so the engine sees one uniform constraint list.
6477    fn parse_column_def_with_fk(
6478        &mut self,
6479    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
6480        let col = self.parse_column_def()?;
6481        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
6482        let inline_references = matches!(
6483            self.peek(),
6484            Token::Ident(s) if s.eq_ignore_ascii_case("references")
6485        );
6486        if !inline_references {
6487            return Ok((col, None));
6488        }
6489        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
6490        let fk = ForeignKeyConstraint {
6491            name: None,
6492            columns: vec![col.name.clone()],
6493            parent_table,
6494            parent_columns,
6495            on_delete,
6496            on_update,
6497        };
6498        Ok((col, Some(fk)))
6499    }
6500
6501    /// v7.13.0 — parse a column type (consuming the type ident and
6502    /// any trailing parameters / `[]`), without surrounding column
6503    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
6504    /// Returns the resolved `ColumnTypeName` plus implied
6505    /// `(auto_increment, not_null)` flags from PG SERIAL family
6506    /// shorthands — callers that don't expect those (ALTER COLUMN
6507    /// TYPE) can discard them.
6508    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
6509        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
6510        Ok(ty)
6511    }
6512
6513    #[allow(clippy::type_complexity)]
6514    fn parse_type_with_implied_flags(
6515        &mut self,
6516    ) -> Result<
6517        (
6518            ColumnTypeName,
6519            bool,
6520            bool,
6521            Option<String>,
6522            Collation,
6523            bool,
6524            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
6525            // list captured at type-parse time. None for all
6526            // non-ENUM types.
6527            Option<Vec<String>>,
6528            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
6529            // list. Distinct from ENUM (subset semantics).
6530            Option<Vec<String>>,
6531        ),
6532        ParseError,
6533    > {
6534        let mut ty_ident = match self.advance() {
6535            Token::Ident(s) => s,
6536            // v7.37.5 β-P2 — `INTERVAL` lexes as a reserved keyword
6537            // (Token::Interval) since v7.9.25 to drive the `INTERVAL
6538            // '<span>'` literal grammar. As a column type it lands
6539            // here directly; downstream resolution still uses the
6540            // canonical lowercase string.
6541            Token::Interval => "interval".to_string(),
6542            other => {
6543                return Err(ParseError {
6544                    message: format!("expected column type, got {other:?}"),
6545                    token_pos: self.pos.saturating_sub(1),
6546                });
6547            }
6548        };
6549        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
6550        // pg_dump qualifies extension types (`public.vector(1024)`).
6551        // SPG is single-namespace; drop the schema and resolve the
6552        // bare type — same treatment table names already get.
6553        while matches!(self.peek(), Token::Dot) {
6554            self.advance();
6555            ty_ident = self.expect_ident_like()?;
6556        }
6557        let mut implied_auto_increment = false;
6558        let mut implied_not_null = false;
6559        let mut user_type_ref: Option<String> = None;
6560        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
6561        // value list, captured here and bubbled up through the
6562        // ColumnDef so the engine can attach it to the column
6563        // schema (and validate INSERT cells against it).
6564        let mut inline_enum_variants: Option<Vec<String>> = None;
6565        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
6566        let mut inline_set_variants: Option<Vec<String>> = None;
6567        let mut ty = match ty_ident.as_str() {
6568            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
6569            "smallserial" | "serial2" => {
6570                implied_auto_increment = true;
6571                implied_not_null = true;
6572                ColumnTypeName::SmallInt
6573            }
6574            "serial" | "serial4" => {
6575                implied_auto_increment = true;
6576                implied_not_null = true;
6577                ColumnTypeName::Int
6578            }
6579            "bigserial" | "serial8" => {
6580                implied_auto_increment = true;
6581                implied_not_null = true;
6582                ColumnTypeName::BigInt
6583            }
6584            // MySQL flavours we accept by aliasing to the closest SPG
6585            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
6586            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
6587            // 24-bit) → INT. UNSIGNED modifiers are consumed below
6588            // without semantic effect.
6589            "smallint" => {
6590                // v7.14.0 — MySQL display-width on integers
6591                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
6592                // parenthesised number is purely cosmetic — it
6593                // doesn't change storage. Accept + discard.
6594                self.consume_optional_paren_size();
6595                ColumnTypeName::SmallInt
6596            }
6597            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
6598            // canonical encoding for BOOLEAN. Every MySQL driver
6599            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
6600            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
6601            // 4.3 SPG classified TINYINT(1) as SmallInt, which
6602            // gave the customer i16-shaped values where the app
6603            // expected bool — a Tier-A silent type drift on
6604            // mysqldump restores. Now: `TINYINT(1)` → Bool;
6605            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
6606            // stay SmallInt (the legacy width-agnostic path).
6607            "tinyint" => {
6608                let width = self.peek_optional_paren_size_value();
6609                self.consume_optional_paren_size();
6610                if width == Some(1) {
6611                    ColumnTypeName::Bool
6612                } else {
6613                    ColumnTypeName::SmallInt
6614                }
6615            }
6616            "int" | "integer" | "mediumint" => {
6617                self.consume_optional_paren_size();
6618                ColumnTypeName::Int
6619            }
6620            "bigint" => {
6621                self.consume_optional_paren_size();
6622                ColumnTypeName::BigInt
6623            }
6624            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6625            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6626            // (mailrs round-5 G6). Consume the optional `PRECISION`
6627            // tail when the type keyword was `double` / `DOUBLE`.
6628            "float" | "double" | "real" => {
6629                if ty_ident.eq_ignore_ascii_case("double")
6630                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6631                {
6632                    self.advance();
6633                }
6634                ColumnTypeName::Float
6635            }
6636            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6637            "float4" | "float8" => ColumnTypeName::Float,
6638            "text" => ColumnTypeName::Text,
6639            "bool" | "boolean" => ColumnTypeName::Bool,
6640            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6641            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6642            "vector" => {
6643                let dim = self.parse_paren_size("VECTOR")?;
6644                let encoding = self.parse_optional_vector_encoding()?;
6645                ColumnTypeName::Vector { dim, encoding }
6646            }
6647            "numeric" => {
6648                let (precision, scale) = self.parse_optional_numeric_params()?;
6649                ColumnTypeName::Numeric(precision, scale)
6650            }
6651            "date" => ColumnTypeName::Date,
6652            // MySQL's `DATETIME` is the same domain as standard
6653            // `TIMESTAMP` — accept both spellings.
6654            "timestamp" | "datetime" => {
6655                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6656                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6657                // the full form. SPG canonicalises:
6658                //   - WITH TIME ZONE    → Timestamptz
6659                //   - WITHOUT TIME ZONE → Timestamp
6660                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6661                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6662                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6663                {
6664                    self.advance(); // WITH
6665                    self.advance(); // TIME
6666                    self.advance(); // ZONE
6667                    ColumnTypeName::Timestamptz
6668                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6669                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6670                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6671                {
6672                    self.advance(); // WITHOUT
6673                    self.advance(); // TIME
6674                    self.advance(); // ZONE
6675                    ColumnTypeName::Timestamp
6676                } else {
6677                    // Optional `(precision)` parenthesised modifier
6678                    // (PG fractional seconds precision). SPG stores
6679                    // µs always; accept + discard.
6680                    self.consume_optional_paren_size();
6681                    ColumnTypeName::Timestamp
6682                }
6683            }
6684            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6685            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6686            // only PG-wire OID differs.
6687            "timestamptz" => ColumnTypeName::Timestamptz,
6688            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6689            // validation. We accept the JSONB spelling too because
6690            // most PG clients default to it; SPG doesn't distinguish
6691            // the two (no path-operator perf advantage to model).
6692            "json" => ColumnTypeName::Json,
6693            "jsonb" => ColumnTypeName::Jsonb,
6694            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6695            // surface here. Same storage shape; mapping happens at
6696            // the engine side via the ColumnTypeName → DataType
6697            // resolver. Literal forms are handled at coerce_value
6698            // time so the lexer stays untouched.
6699            "bytea" | "bytes" => ColumnTypeName::Bytes,
6700            // v7.17.0 Phase 7 — PG network address types
6701            // v7.17.0 had a Text-backed fallback here for
6702            // `inet` / `cidr` / `macaddr`. v7.37.5 ζ-A promoted
6703            // each to a first-class type; the keywords are
6704            // bound below in the ζ-A block.
6705            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6706            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6707            // arrives in v7.12.1+; the type itself loads here so
6708            // mailrs's `scripts/init-schema.sql` runs unmodified.
6709            "tsvector" => ColumnTypeName::TsVector,
6710            "tsquery" => ColumnTypeName::TsQuery,
6711            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6712            // surface for Django / Rails / Hibernate's default
6713            // PK pattern.
6714            "uuid" => ColumnTypeName::Uuid,
6715            // v7.37.5 β-P2 — PG `INTERVAL` as a column type.
6716            // Storage = three-field {months, days, micros}, catalog
6717            // tag 34, FILE_VERSION 48+, wire OID 1186. Prior to this
6718            // line `INTERVAL` was parser-rejected at CREATE TABLE.
6719            "interval" => ColumnTypeName::Interval,
6720            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6721            // i64 microseconds since 00:00:00. Wire OID 1083.
6722            "time" => ColumnTypeName::Time,
6723            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6724            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6725            "year" => ColumnTypeName::Year,
6726            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6727            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6728            "timetz" => ColumnTypeName::TimeTz,
6729            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6730            // Wire OID 790.
6731            "money" => ColumnTypeName::Money,
6732            // v7.17.0 Phase 3.P0-38 — PG range types.
6733            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6734            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6735            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6736            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6737            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6738            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6739            // v7.37.5 δ — PG 14+ multirange keywords.
6740            "int4multirange" => ColumnTypeName::Multirange(RangeKindAst::Int4),
6741            "int8multirange" => ColumnTypeName::Multirange(RangeKindAst::Int8),
6742            "nummultirange" => ColumnTypeName::Multirange(RangeKindAst::Num),
6743            "tsmultirange" => ColumnTypeName::Multirange(RangeKindAst::Ts),
6744            "tstzmultirange" => ColumnTypeName::Multirange(RangeKindAst::TsTz),
6745            "datemultirange" => ColumnTypeName::Multirange(RangeKindAst::Date),
6746            // v7.37.5 ε — PG geometry scalar keywords.
6747            "point" => ColumnTypeName::Point,
6748            "lseg" => ColumnTypeName::Lseg,
6749            "path" => ColumnTypeName::Path,
6750            "box" => ColumnTypeName::PgBox,
6751            "polygon" => ColumnTypeName::Polygon,
6752            "line" => ColumnTypeName::Line,
6753            "circle" => ColumnTypeName::Circle,
6754            // v7.37.5 ζ-A — network / bit / xml / "char" keywords.
6755            "inet" => ColumnTypeName::Inet,
6756            "cidr" => ColumnTypeName::Cidr,
6757            "macaddr" => ColumnTypeName::Macaddr,
6758            "macaddr8" => ColumnTypeName::Macaddr8,
6759            "bit" => ColumnTypeName::Bit,
6760            "varbit" => ColumnTypeName::BitVarying,
6761            "xml" => ColumnTypeName::Xml,
6762            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6763            "hstore" => ColumnTypeName::Hstore,
6764            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6765            // `ENUM('a','b','c')`. Storage is TEXT; the value
6766            // list lands on `inline_enum_variants` for the
6767            // engine to validate INSERT cells against. Empty
6768            // value list is a parse error (matches MySQL).
6769            "enum" => {
6770                // Expect the opening `(`.
6771                if !matches!(self.peek(), Token::LParen) {
6772                    return Err(self.err(alloc::format!(
6773                        "expected '(' after ENUM, got {:?}",
6774                        self.peek()
6775                    )));
6776                }
6777                self.advance();
6778                let mut variants: Vec<String> = Vec::new();
6779                loop {
6780                    match self.advance() {
6781                        Token::String(s) => variants.push(s),
6782                        other => {
6783                            return Err(self.err(alloc::format!(
6784                                "ENUM(...) expects string literal variants, got {other:?}"
6785                            )));
6786                        }
6787                    }
6788                    match self.peek() {
6789                        Token::Comma => {
6790                            self.advance();
6791                            continue;
6792                        }
6793                        Token::RParen => {
6794                            self.advance();
6795                            break;
6796                        }
6797                        other => {
6798                            return Err(self.err(alloc::format!(
6799                                "expected ',' or ')' in ENUM(...), got {other:?}"
6800                            )));
6801                        }
6802                    }
6803                }
6804                if variants.is_empty() {
6805                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6806                }
6807                inline_enum_variants = Some(variants);
6808                // Storage is plain TEXT; the variant list lives on
6809                // the ColumnSchema side.
6810                ColumnTypeName::Text
6811            }
6812            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6813            // `SET('a','b','c')`. Same parse shape as ENUM;
6814            // semantics differ (subset rather than pick-one).
6815            "set" => {
6816                if !matches!(self.peek(), Token::LParen) {
6817                    return Err(self.err(alloc::format!(
6818                        "expected '(' after SET, got {:?}",
6819                        self.peek()
6820                    )));
6821                }
6822                self.advance();
6823                let mut variants: Vec<String> = Vec::new();
6824                loop {
6825                    match self.advance() {
6826                        Token::String(s) => variants.push(s),
6827                        other => {
6828                            return Err(self.err(alloc::format!(
6829                                "SET(...) expects string literal variants, got {other:?}"
6830                            )));
6831                        }
6832                    }
6833                    match self.peek() {
6834                        Token::Comma => {
6835                            self.advance();
6836                            continue;
6837                        }
6838                        Token::RParen => {
6839                            self.advance();
6840                            break;
6841                        }
6842                        other => {
6843                            return Err(self.err(alloc::format!(
6844                                "expected ',' or ')' in SET(...), got {other:?}"
6845                            )));
6846                        }
6847                    }
6848                }
6849                if variants.is_empty() {
6850                    return Err(self.err("SET(...) must declare at least one variant".into()));
6851                }
6852                inline_set_variants = Some(variants);
6853                ColumnTypeName::Text
6854            }
6855            _other => {
6856                // v7.17.0 Phase 1.4 — unknown ident → defer
6857                // resolution to the engine. Stored as Text in
6858                // ColumnTypeName + the original name carried as
6859                // `user_type_ref` so CREATE TABLE can look up
6860                // user-defined enum / domain types.
6861                user_type_ref = Some(ty_ident.clone());
6862                ColumnTypeName::Text
6863            }
6864        };
6865        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6866        // right after the type keyword. Pre-4.4 SPG consumed +
6867        // discarded the keyword, leaving a customer column
6868        // declared `id INT UNSIGNED NOT NULL` silently accepting
6869        // negative values — a Tier-A correctness drift where
6870        // application invariants (auto-increment-IDs never
6871        // negative) silently broke on cutover. Now: capture as
6872        // a column flag, persist on the schema, enforce at
6873        // INSERT / UPDATE time.
6874        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6875        {
6876            self.advance();
6877            true
6878        } else {
6879            false
6880        };
6881        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6882        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6883        // stores text as UTF-8 always so CHARACTER SET is still a
6884        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6885        // name: it gets classified into a `Collation` variant the
6886        // engine consults at WHERE-eval time. PG `default` /
6887        // `pg_catalog.default` / `C` / `POSIX` collations all
6888        // resolve to `Binary` (the prior behaviour); `_ci` /
6889        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6890        // The schema-qualifier form (`pg_catalog.default`) lexes
6891        // as `Ident '.' Ident` — peek for the `.` and consume both
6892        // halves so it's treated as one collation name. PG's
6893        // `IDENT.IDENT` collation form (which can appear here) is
6894        // resolved by Collation::from_collation_name on the bare
6895        // identifier after the dot.
6896        let mut collation = Collation::Binary;
6897        loop {
6898            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6899                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6900            {
6901                self.advance(); // CHARACTER
6902                self.advance(); // SET
6903                if matches!(
6904                    self.peek(),
6905                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6906                ) {
6907                    self.advance();
6908                }
6909                continue;
6910            }
6911            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6912                self.advance(); // COLLATE
6913                // Accept Ident / QuotedIdent / String AND the
6914                // keyword-tokenised `Default` (PG `pg_catalog.default`
6915                // and bare `DEFAULT` collation names — `default` is a
6916                // reserved word so the lexer hands back Token::Default
6917                // not Token::Ident).
6918                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6919                    match this.peek().clone() {
6920                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6921                            this.advance();
6922                            Some(s)
6923                        }
6924                        Token::Default => {
6925                            this.advance();
6926                            Some(alloc::string::String::from("default"))
6927                        }
6928                        _ => None,
6929                    }
6930                };
6931                let raw = if let Some(head) = read_collation_atom(self) {
6932                    // Schema-qualified PG form: `pg_catalog.default`.
6933                    if matches!(self.peek(), Token::Dot) {
6934                        self.advance();
6935                        let tail = read_collation_atom(self).unwrap_or_default();
6936                        alloc::format!("{head}.{tail}")
6937                    } else {
6938                        head
6939                    }
6940                } else {
6941                    alloc::string::String::new()
6942                };
6943                if !raw.is_empty() {
6944                    let parsed = Collation::from_collation_name(&raw);
6945                    // Last COLLATE clause wins, but `Binary` from a
6946                    // bare keyword like `default` should not
6947                    // silently downgrade a stronger one set earlier
6948                    // on the same column. v7.17 only ships one
6949                    // non-Binary variant so a simple OR is enough.
6950                    if parsed != Collation::Binary {
6951                        collation = parsed;
6952                    }
6953                }
6954                continue;
6955            }
6956            break;
6957        }
6958        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6959        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6960        // so we reject other base types here. mailrs uses TEXT[]
6961        // for labels / addresses / message-on-thread.
6962        if matches!(self.peek(), Token::LBracket) {
6963            self.advance();
6964            if !matches!(self.peek(), Token::RBracket) {
6965                return Err(self.err(alloc::format!(
6966                    "TEXT[] takes no dimension; got {:?}",
6967                    self.peek()
6968                )));
6969            }
6970            self.advance();
6971            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6972            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6973            // still error here.
6974            ty = match ty {
6975                ColumnTypeName::Text => ColumnTypeName::TextArray,
6976                ColumnTypeName::Int => ColumnTypeName::IntArray,
6977                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6978                // v7.37.5 β-P4 — INTERVAL[] via the same postfix
6979                // `[]` grammar. Wire OID 1187.
6980                ColumnTypeName::Interval => ColumnTypeName::IntervalArray,
6981                // v7.37.5 γ — full PG array-of-scalar family.
6982                ColumnTypeName::Bool => ColumnTypeName::BoolArray,
6983                ColumnTypeName::SmallInt => ColumnTypeName::SmallIntArray,
6984                ColumnTypeName::Float => ColumnTypeName::FloatArray,
6985                // NUMERIC(p, s) loses its precision params at the
6986                // array level (matches PG: `NUMERIC[]` is untyped,
6987                // per-element precision flows through values).
6988                ColumnTypeName::Numeric(_, _) => ColumnTypeName::NumericArray,
6989                ColumnTypeName::Date => ColumnTypeName::DateArray,
6990                ColumnTypeName::Timestamp => ColumnTypeName::TimestampArray,
6991                ColumnTypeName::Timestamptz => ColumnTypeName::TimestamptzArray,
6992                ColumnTypeName::Uuid => ColumnTypeName::UuidArray,
6993                ColumnTypeName::Json => ColumnTypeName::JsonArray,
6994                ColumnTypeName::Jsonb => ColumnTypeName::JsonbArray,
6995                ColumnTypeName::Bytes => ColumnTypeName::BytesArray,
6996                // VARCHAR(n)[] / CHAR(n)[] drop the length cap at
6997                // the array level (matches PG semantics where the
6998                // element precision is per-row, not column-wide).
6999                ColumnTypeName::Varchar(_) => ColumnTypeName::VarcharArray,
7000                ColumnTypeName::Char(_) => ColumnTypeName::CharArray,
7001                // v7.37.5 ζ-A — MONEY[] (OID 791) ship-triage
7002                // follow-up.
7003                ColumnTypeName::Money => ColumnTypeName::MoneyArray,
7004                other => {
7005                    return Err(self.err(alloc::format!("{other:?}[] not yet supported")));
7006                }
7007            };
7008            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
7009            // for INT/TEXT/BIGINT. Anything else is an error.
7010            if matches!(self.peek(), Token::LBracket) {
7011                self.advance();
7012                if !matches!(self.peek(), Token::RBracket) {
7013                    return Err(self.err(alloc::format!(
7014                        "TYPE[][] second dimension takes no size; got {:?}",
7015                        self.peek()
7016                    )));
7017                }
7018                self.advance();
7019                ty = match ty {
7020                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
7021                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
7022                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
7023                    other => {
7024                        return Err(self.err(alloc::format!(
7025                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
7026                             TEXT[][] only; got {other:?}"
7027                        )));
7028                    }
7029                };
7030            }
7031        }
7032        Ok((
7033            ty,
7034            implied_auto_increment,
7035            implied_not_null,
7036            user_type_ref,
7037            collation,
7038            is_unsigned,
7039            inline_enum_variants,
7040            inline_set_variants,
7041        ))
7042    }
7043
7044    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
7045        // v7.20 — PG reserves the table-constraint keywords, so a
7046        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
7047        // malformed constraint clause (e.g. `UNIQUE a` missing its
7048        // parens), not a column named "unique". Since v7.17's
7049        // unknown-type leniency (`user_type_ref`) such a clause
7050        // would otherwise parse as a column with a user-defined
7051        // type — silently accepting invalid DDL. Quoted
7052        // identifiers ("unique" / `unique`) remain valid names.
7053        if let Token::Ident(s) = self.peek()
7054            && [
7055                "unique",
7056                "primary",
7057                "foreign",
7058                "constraint",
7059                "check",
7060                "references",
7061                "exclude",
7062            ]
7063            .iter()
7064            .any(|kw| s.eq_ignore_ascii_case(kw))
7065        {
7066            return Err(self.err(alloc::format!(
7067                "unexpected reserved keyword '{s}' at start of column definition \
7068                 (malformed table constraint?)"
7069            )));
7070        }
7071        let name = self.expect_ident_like()?;
7072        let (
7073            ty,
7074            implied_auto_increment,
7075            implied_not_null,
7076            user_type_ref,
7077            collation,
7078            is_unsigned,
7079            inline_enum_variants,
7080            inline_set_variants,
7081        ) = self.parse_type_with_implied_flags()?;
7082        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
7083        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
7084        // each at most once.
7085        let mut default: Option<Expr> = None;
7086        let mut nullable = !implied_not_null;
7087        let mut nullability_seen = implied_not_null;
7088        let mut auto_increment = implied_auto_increment;
7089        let mut is_primary_key = false;
7090        let mut is_unique = false;
7091        let mut check: Option<Expr> = None;
7092        let mut on_update_runtime: Option<Expr> = None;
7093        let mut generated_stored_expr: Option<Box<Expr>> = None;
7094        loop {
7095            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
7096            // not-null constraints by name and pg_dump emits them
7097            // inline: `id bigint CONSTRAINT contacts_id_not_null1
7098            // NOT NULL`. Accept and discard the name; whatever
7099            // constraint follows is parsed by the arms below.
7100            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
7101                self.advance();
7102                let _name = self.expect_ident_like()?;
7103                continue;
7104            }
7105            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
7106            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
7107            // the modern replacement for SERIAL in hand-written
7108            // schemas). Both flavours map onto the auto-increment
7109            // machinery — SPG's serial semantics ≈ BY DEFAULT;
7110            // ALWAYS's reject-explicit-values nuance is documented
7111            // leniency. Generated EXPRESSION columns
7112            // (`AS (expr) STORED`) are not supported: error loudly
7113            // instead of silently storing NULLs.
7114            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
7115                self.advance();
7116                match self.peek().clone() {
7117                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
7118                        self.advance();
7119                    }
7120                    // `BY` is a reserved keyword token (GROUP BY).
7121                    Token::By => {
7122                        self.advance();
7123                        if !matches!(self.peek(), Token::Default) {
7124                            return Err(self.err(alloc::format!(
7125                                "expected DEFAULT after GENERATED BY, got {:?}",
7126                                self.peek()
7127                            )));
7128                        }
7129                        self.advance();
7130                    }
7131                    other => {
7132                        return Err(self.err(alloc::format!(
7133                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
7134                        )));
7135                    }
7136                }
7137                if !matches!(self.peek(), Token::As) {
7138                    return Err(self.err(alloc::format!(
7139                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
7140                        self.peek()
7141                    )));
7142                }
7143                self.advance();
7144                // v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS
7145                // ( <expr> ) STORED` stored computed-column. The
7146                // expression is captured for the engine to recompute
7147                // on every INSERT / UPDATE. v7.37.7 accepts the
7148                // STORED keyword only; PG also has VIRTUAL, which
7149                // v7.37.7 carves out (sentori only uses STORED).
7150                if matches!(self.peek(), Token::LParen) {
7151                    self.advance();
7152                    let expr = self.parse_expr(0)?;
7153                    if !matches!(self.peek(), Token::RParen) {
7154                        return Err(self.err(alloc::format!(
7155                            "expected ')' after GENERATED ALWAYS AS (<expr>), got {:?}",
7156                            self.peek()
7157                        )));
7158                    }
7159                    self.advance();
7160                    let stored = match self.peek() {
7161                        Token::Ident(s) | Token::QuotedIdent(s)
7162                            if s.eq_ignore_ascii_case("stored") =>
7163                        {
7164                            self.advance();
7165                            true
7166                        }
7167                        Token::Ident(s) | Token::QuotedIdent(s)
7168                            if s.eq_ignore_ascii_case("virtual") =>
7169                        {
7170                            return Err(self.err(
7171                                "GENERATED ALWAYS AS (expr) VIRTUAL is not supported \
7172                                 at v7.37.7; use STORED"
7173                                    .into(),
7174                            ));
7175                        }
7176                        other => {
7177                            return Err(self.err(alloc::format!(
7178                                "expected STORED after GENERATED ALWAYS AS (<expr>), \
7179                                 got {other:?}"
7180                            )));
7181                        }
7182                    };
7183                    let _ = stored; // currently STORED-only; flag reserved for VIRTUAL.
7184                    generated_stored_expr = Some(Box::new(expr));
7185                    continue;
7186                }
7187                self.expect_keyword_ident("identity")?;
7188                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
7189                // consume the balanced parens and discard (SPG's
7190                // auto-increment is max+1-scan based).
7191                if matches!(self.peek(), Token::LParen) {
7192                    let mut depth = 0usize;
7193                    loop {
7194                        match self.advance() {
7195                            Token::LParen => depth += 1,
7196                            Token::RParen => {
7197                                depth -= 1;
7198                                if depth == 0 {
7199                                    break;
7200                                }
7201                            }
7202                            Token::Eof => {
7203                                return Err(self.err(
7204                                    "unterminated sequence-options parens after IDENTITY".into(),
7205                                ));
7206                            }
7207                            _ => {}
7208                        }
7209                    }
7210                }
7211                auto_increment = true;
7212                // PG identity columns are implicitly NOT NULL.
7213                nullable = false;
7214                continue;
7215            }
7216            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
7217            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
7218            // is accepted today. The "ON" token is an Ident
7219            // (not reserved) — peek before consuming.
7220            if matches!(self.peek(), Token::On)
7221                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
7222            {
7223                self.advance(); // ON
7224                self.advance(); // update
7225                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
7226                let next = self.peek().clone();
7227                match next {
7228                    Token::Ident(s) | Token::QuotedIdent(s)
7229                        if s.eq_ignore_ascii_case("current_timestamp") =>
7230                    {
7231                        self.advance();
7232                        // Optional `(N)` precision.
7233                        if matches!(self.peek(), Token::LParen) {
7234                            self.advance();
7235                            if !matches!(self.peek(), Token::Integer(_)) {
7236                                return Err(self.err(alloc::format!(
7237                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
7238                                    self.peek()
7239                                )));
7240                            }
7241                            self.advance();
7242                            if !matches!(self.peek(), Token::RParen) {
7243                                return Err(self.err(alloc::format!(
7244                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
7245                                    self.peek()
7246                                )));
7247                            }
7248                            self.advance();
7249                        }
7250                        on_update_runtime = Some(Expr::FunctionCall {
7251                            name: "now".into(),
7252                            args: Vec::new(),
7253                        });
7254                        continue;
7255                    }
7256                    other => {
7257                        return Err(self.err(alloc::format!(
7258                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
7259                        )));
7260                    }
7261                }
7262            }
7263            if matches!(self.peek(), Token::Default) {
7264                if default.is_some() {
7265                    return Err(self.err("DEFAULT specified twice".into()));
7266                }
7267                self.advance();
7268                default = Some(self.parse_expr(0)?);
7269                continue;
7270            }
7271            if matches!(self.peek(), Token::Not) {
7272                if nullability_seen {
7273                    return Err(self.err("NOT NULL specified twice".into()));
7274                }
7275                self.advance();
7276                if !matches!(self.peek(), Token::Null) {
7277                    return Err(self.err(format!(
7278                        "expected NULL after NOT in column def, got {:?}",
7279                        self.peek()
7280                    )));
7281                }
7282                self.advance();
7283                nullable = false;
7284                nullability_seen = true;
7285                continue;
7286            }
7287            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
7288            // "this column is nullable" marker (the default in
7289            // standard SQL anyway). mysqldump emits it routinely
7290            // (`col TYPE NULL DEFAULT NULL` for nullable
7291            // timestamps etc). Accept + no-op.
7292            if matches!(self.peek(), Token::Null) {
7293                if nullability_seen && !nullable {
7294                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
7295                }
7296                self.advance();
7297                nullable = true;
7298                nullability_seen = true;
7299                continue;
7300            }
7301            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
7302            // arrives as a bare Ident. Match either, case-insensitive.
7303            if let Token::Ident(s) = self.peek()
7304                && (s.eq_ignore_ascii_case("auto_increment")
7305                    || s.eq_ignore_ascii_case("autoincrement"))
7306            {
7307                if auto_increment {
7308                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
7309                }
7310                self.advance();
7311                auto_increment = true;
7312                continue;
7313            }
7314            // v7.9.13 — inline `PRIMARY KEY` column constraint
7315            // (mailrs F1). Implies `NOT NULL`. The engine creates
7316            // a BTree index for the PK column at CREATE TABLE time
7317            // so FK parent-side index lookups resolve.
7318            if let Token::Ident(s) = self.peek()
7319                && s.eq_ignore_ascii_case("primary")
7320            {
7321                if is_primary_key {
7322                    return Err(self.err("PRIMARY KEY specified twice".into()));
7323                }
7324                // Peek-ahead for the required `KEY` token.
7325                let next = self.tokens.get(self.pos + 1);
7326                let next_is_key = matches!(
7327                    next,
7328                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
7329                );
7330                if !next_is_key {
7331                    return Err(self.err(format!(
7332                        "expected KEY after PRIMARY in column def, got {:?}",
7333                        next
7334                    )));
7335                }
7336                self.advance(); // PRIMARY
7337                self.advance(); // KEY
7338                is_primary_key = true;
7339                if nullability_seen && nullable {
7340                    return Err(self.err(
7341                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
7342                    ));
7343                }
7344                nullable = false;
7345                nullability_seen = true;
7346                continue;
7347            }
7348            // v7.13.0 — inline `UNIQUE` column constraint
7349            // (mailrs round-5 G2). Fold into a single-column
7350            // table-level UNIQUE at CREATE TABLE post-process time.
7351            if let Token::Ident(s) = self.peek()
7352                && s.eq_ignore_ascii_case("unique")
7353            {
7354                if is_unique {
7355                    return Err(self.err("UNIQUE specified twice".into()));
7356                }
7357                self.advance();
7358                is_unique = true;
7359                continue;
7360            }
7361            // v7.13.0 — inline `CHECK (<expr>)` column constraint
7362            // (mailrs round-5 G3). PG semantics: column-level
7363            // CHECK is equivalent to a table-level CHECK. Multiple
7364            // inline CHECKs on the same column AND together.
7365            if let Token::Ident(s) = self.peek()
7366                && s.eq_ignore_ascii_case("check")
7367            {
7368                self.advance();
7369                if !matches!(self.peek(), Token::LParen) {
7370                    return Err(self.err(alloc::format!(
7371                        "expected '(' after CHECK in column def, got {:?}",
7372                        self.peek()
7373                    )));
7374                }
7375                self.advance();
7376                let pred = self.parse_expr(0)?;
7377                if !matches!(self.peek(), Token::RParen) {
7378                    return Err(self.err(alloc::format!(
7379                        "expected ')' to close CHECK predicate, got {:?}",
7380                        self.peek()
7381                    )));
7382                }
7383                self.advance();
7384                check = Some(match check.take() {
7385                    Some(prev) => Expr::Binary {
7386                        op: BinOp::And,
7387                        lhs: Box::new(prev),
7388                        rhs: Box::new(pred),
7389                    },
7390                    None => pred,
7391                });
7392                continue;
7393            }
7394            break;
7395        }
7396        Ok(ColumnDef {
7397            name,
7398            ty,
7399            nullable,
7400            default,
7401            auto_increment,
7402            is_primary_key,
7403            is_unique,
7404            check,
7405            user_type_ref,
7406            on_update_runtime,
7407            collation,
7408            is_unsigned,
7409            inline_enum_variants,
7410            inline_set_variants,
7411            generated_stored_expr,
7412        })
7413    }
7414
7415    /// `NUMERIC` may appear without parameters, with one (precision
7416    /// only, scale=0), or with both. Returns `(precision, scale)` with
7417    /// 0 = unspecified for the bare form.
7418    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
7419        if !matches!(self.peek(), Token::LParen) {
7420            // Bare `NUMERIC` — PG treats this as "unlimited precision";
7421            // we surface it as precision=0 to mean "unconstrained" so
7422            // the engine doesn't need a separate variant.
7423            return Ok((0, 0));
7424        }
7425        self.advance();
7426        let precision = match self.advance() {
7427            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
7428            other => {
7429                return Err(ParseError {
7430                    message: format!(
7431                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
7432                    ),
7433                    token_pos: self.pos.saturating_sub(1),
7434                });
7435            }
7436        };
7437        let scale = if matches!(self.peek(), Token::Comma) {
7438            self.advance();
7439            match self.advance() {
7440                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
7441                    u8::try_from(n).expect("range-checked")
7442                }
7443                other => {
7444                    return Err(ParseError {
7445                        message: format!(
7446                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
7447                        ),
7448                        token_pos: self.pos.saturating_sub(1),
7449                    });
7450                }
7451            }
7452        } else {
7453            0
7454        };
7455        if !matches!(self.peek(), Token::RParen) {
7456            return Err(self.err(format!(
7457                "expected ')' to close NUMERIC params, got {:?}",
7458                self.peek()
7459            )));
7460        }
7461        self.advance();
7462        Ok((precision, scale))
7463    }
7464
7465    /// Parse `(N)` where `N` is a positive integer literal — used by the
7466    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
7467    /// for the error message.
7468    /// v6.0.1: parse the optional `USING <encoding>` clause that
7469    /// follows `VECTOR(N)` in a column definition. Missing clause
7470    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
7471    /// ident → `ParseError` listing the encodings recognised today.
7472    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
7473        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
7474            return Ok(VecEncoding::F32);
7475        }
7476        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
7477        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
7478        // consume the token when the very next token is a known
7479        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
7480        // `USING` for the caller — it's the rewrite-expression form.
7481        let n1 = self.tokens.get(self.pos + 1);
7482        let next_is_encoding = matches!(
7483            n1,
7484            Some(Token::Ident(s))
7485                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
7486        );
7487        if !next_is_encoding {
7488            return Ok(VecEncoding::F32);
7489        }
7490        self.advance();
7491        let enc_ident = match self.advance() {
7492            Token::Ident(s) => s,
7493            other => {
7494                return Err(self.err(format!(
7495                    "expected vector encoding after USING, got {other:?}"
7496                )));
7497            }
7498        };
7499        match enc_ident.to_ascii_lowercase().as_str() {
7500            "sq8" => Ok(VecEncoding::Sq8),
7501            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
7502            // binary16 per-element storage.
7503            "half" => Ok(VecEncoding::F16),
7504            other => Err(self.err(format!(
7505                "unknown vector encoding {other:?}; supported: SQ8, HALF"
7506            ))),
7507        }
7508    }
7509
7510    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
7511    /// without consuming it. Returns `Some(N)` when the next
7512    /// tokens are `( <int> )`; None otherwise. Used by the
7513    /// TINYINT classifier to decide whether to map to Bool or
7514    /// SmallInt.
7515    fn peek_optional_paren_size_value(&self) -> Option<i64> {
7516        if !matches!(self.peek(), Token::LParen) {
7517            return None;
7518        }
7519        let next = self.tokens.get(self.pos + 1)?;
7520        let n = match next {
7521            Token::Integer(n) => *n,
7522            _ => return None,
7523        };
7524        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
7525            return None;
7526        }
7527        Some(n)
7528    }
7529
7530    /// v7.14.0 — consume an optional MySQL display-width
7531    /// parenthesised number after an integer type, returning
7532    /// nothing. `TINYINT(1)` etc.
7533    fn consume_optional_paren_size(&mut self) {
7534        if !matches!(self.peek(), Token::LParen) {
7535            return;
7536        }
7537        self.advance();
7538        // Skip until matching RParen (allow nested or any tokens).
7539        let mut depth = 1usize;
7540        while depth > 0 {
7541            match self.peek() {
7542                Token::LParen => depth += 1,
7543                Token::RParen => depth -= 1,
7544                Token::Eof => return,
7545                _ => {}
7546            }
7547            self.advance();
7548        }
7549    }
7550
7551    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
7552        if !matches!(self.peek(), Token::LParen) {
7553            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
7554        }
7555        self.advance();
7556        let n = match self.advance() {
7557            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
7558                message: format!("{label} size too large: {n}"),
7559                token_pos: self.pos.saturating_sub(1),
7560            })?,
7561            other => {
7562                return Err(ParseError {
7563                    message: format!("expected positive integer {label} size, got {other:?}"),
7564                    token_pos: self.pos.saturating_sub(1),
7565                });
7566            }
7567        };
7568        if !matches!(self.peek(), Token::RParen) {
7569            return Err(self.err(format!(
7570                "expected ')' after {label} size, got {:?}",
7571                self.peek()
7572            )));
7573        }
7574        self.advance();
7575        Ok(n)
7576    }
7577
7578    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
7579        debug_assert!(matches!(self.peek(), Token::Insert));
7580        self.advance();
7581        if !matches!(self.peek(), Token::Into) {
7582            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
7583        }
7584        self.advance();
7585        let table = self.expect_ident_like()?;
7586        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
7587        let columns = if matches!(self.peek(), Token::LParen) {
7588            self.advance();
7589            let mut names = Vec::new();
7590            loop {
7591                names.push(self.expect_ident_like()?);
7592                match self.peek() {
7593                    Token::Comma => {
7594                        self.advance();
7595                    }
7596                    Token::RParen => {
7597                        self.advance();
7598                        break;
7599                    }
7600                    other => {
7601                        return Err(self.err(format!(
7602                            "expected ',' or ')' in INSERT column list, got {other:?}"
7603                        )));
7604                    }
7605                }
7606            }
7607            Some(names)
7608        } else {
7609            None
7610        };
7611        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
7612        // round-5 G4). Dispatch on VALUES vs SELECT.
7613        if matches!(self.peek(), Token::Select) {
7614            let select_stmt = match self.parse_select_stmt()? {
7615                Statement::Select(s) => s,
7616                other => {
7617                    return Err(self.err(alloc::format!(
7618                        "expected SELECT after INSERT INTO ... target, got {other:?}"
7619                    )));
7620                }
7621            };
7622            let on_conflict = self.parse_optional_on_conflict()?;
7623            let returning = self.parse_optional_returning()?;
7624            return Ok(Statement::Insert(InsertStatement {
7625                ctes: Vec::new(),
7626                table,
7627                columns,
7628                rows: Vec::new(),
7629                select_source: Some(Box::new(select_stmt)),
7630                on_conflict,
7631                returning,
7632            }));
7633        }
7634        if !matches!(self.peek(), Token::Values) {
7635            return Err(self.err(format!(
7636                "expected VALUES or SELECT after table name, got {:?}",
7637                self.peek()
7638            )));
7639        }
7640        self.advance();
7641        if !matches!(self.peek(), Token::LParen) {
7642            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
7643        }
7644        let mut rows = Vec::new();
7645        loop {
7646            // Each iteration consumes one `(expr, expr, …)` tuple.
7647            if !matches!(self.peek(), Token::LParen) {
7648                return Err(self.err(format!(
7649                    "expected '(' for next VALUES tuple, got {:?}",
7650                    self.peek()
7651                )));
7652            }
7653            self.advance();
7654            let mut tuple = Vec::new();
7655            loop {
7656                tuple.push(self.parse_expr(0)?);
7657                match self.peek() {
7658                    Token::Comma => {
7659                        self.advance();
7660                    }
7661                    Token::RParen => {
7662                        self.advance();
7663                        break;
7664                    }
7665                    other => {
7666                        return Err(self.err(format!(
7667                            "expected ',' or ')' in VALUES tuple, got {other:?}"
7668                        )));
7669                    }
7670                }
7671            }
7672            if tuple.is_empty() {
7673                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
7674            }
7675            rows.push(tuple);
7676            // Continue with comma-separated tuples.
7677            if matches!(self.peek(), Token::Comma) {
7678                self.advance();
7679            } else {
7680                break;
7681            }
7682        }
7683        let on_conflict = self.parse_optional_on_conflict()?;
7684        let returning = self.parse_optional_returning()?;
7685        Ok(Statement::Insert(InsertStatement {
7686            ctes: Vec::new(),
7687            table,
7688            columns,
7689            rows,
7690            select_source: None,
7691            on_conflict,
7692            returning,
7693        }))
7694    }
7695
7696    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
7697    /// clause sitting between the INSERT body and the trailing
7698    /// RETURNING. All keywords come in as bare idents; `ON` is
7699    /// a reserved Token though.
7700    fn parse_optional_on_conflict(
7701        &mut self,
7702    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7703        if !matches!(self.peek(), Token::On) {
7704            return Ok(None);
7705        }
7706        // Peek further: we want exactly "ON CONFLICT ...". If the
7707        // next ident isn't "conflict", let some other parser handle.
7708        let next_is_conflict = matches!(
7709            self.tokens.get(self.pos + 1),
7710            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7711        );
7712        if !next_is_conflict {
7713            return Ok(None);
7714        }
7715        self.advance(); // ON
7716        self.advance(); // CONFLICT
7717        // Optional `(col [, col]*)` target list.
7718        let mut target_columns: Vec<String> = Vec::new();
7719        if matches!(self.peek(), Token::LParen) {
7720            self.advance();
7721            loop {
7722                target_columns.push(self.expect_ident_like()?);
7723                match self.peek() {
7724                    Token::Comma => {
7725                        self.advance();
7726                    }
7727                    Token::RParen => {
7728                        self.advance();
7729                        break;
7730                    }
7731                    other => {
7732                        return Err(self.err(alloc::format!(
7733                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7734                        )));
7735                    }
7736                }
7737            }
7738        }
7739        // Required `DO`.
7740        match self.advance() {
7741            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7742            other => {
7743                return Err(self.err(alloc::format!(
7744                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7745                )));
7746            }
7747        }
7748        // Action: NOTHING | UPDATE SET …
7749        let action = match self.advance() {
7750            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7751                crate::ast::OnConflictAction::Nothing
7752            }
7753            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7754                self.parse_on_conflict_update_action()?
7755            }
7756            other => {
7757                return Err(self.err(alloc::format!(
7758                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7759                )));
7760            }
7761        };
7762        Ok(Some(crate::ast::OnConflictClause {
7763            target_columns,
7764            action,
7765        }))
7766    }
7767
7768    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7769    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7770    /// consumed `UPDATE`.
7771    fn parse_on_conflict_update_action(
7772        &mut self,
7773    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7774        // `SET`
7775        match self.advance() {
7776            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7777            other => {
7778                return Err(self.err(alloc::format!(
7779                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7780                )));
7781            }
7782        }
7783        let mut assignments: Vec<(String, Expr)> = Vec::new();
7784        loop {
7785            let col = self.expect_ident_like()?;
7786            if !matches!(self.peek(), Token::Eq) {
7787                return Err(self.err(alloc::format!(
7788                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7789                    self.peek()
7790                )));
7791            }
7792            self.advance();
7793            let value = self.parse_expr(0)?;
7794            assignments.push((col, value));
7795            if matches!(self.peek(), Token::Comma) {
7796                self.advance();
7797                continue;
7798            }
7799            break;
7800        }
7801        let where_ = if matches!(self.peek(), Token::Where) {
7802            self.advance();
7803            Some(self.parse_expr(0)?)
7804        } else {
7805            None
7806        };
7807        Ok(crate::ast::OnConflictAction::Update {
7808            assignments,
7809            where_,
7810        })
7811    }
7812
7813    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7814        let mut items = Vec::new();
7815        loop {
7816            items.push(self.parse_select_item()?);
7817            if matches!(self.peek(), Token::Comma) {
7818                self.advance();
7819            } else {
7820                break;
7821            }
7822        }
7823        Ok(items)
7824    }
7825
7826    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7827        if matches!(self.peek(), Token::Star) {
7828            self.advance();
7829            return Ok(SelectItem::Wildcard);
7830        }
7831        let expr = self.parse_expr(0)?;
7832        let alias = self.parse_optional_alias();
7833        Ok(SelectItem::Expr { expr, alias })
7834    }
7835
7836    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7837        // v7.37.43-T4.5 — `LATERAL jsonb_each_text(<expr>)` —
7838        // set-returning function whose argument may reference a
7839        // preceding FROM item. We rewrite this to
7840        // `LATERAL (SELECT key, value FROM jsonb_each_text(<expr>)
7841        // AS __srf__) AS <alias>` so the existing LATERAL subquery
7842        // executor handles per-outer-row evaluation and the
7843        // SRF-primary jsonb_each_text path handles the inner
7844        // materialisation. Sentori 0067 backfill is the dogfood
7845        // shape.
7846        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7847            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("jsonb_each_text"))
7848            && matches!(self.tokens.get(self.pos + 2), Some(Token::LParen))
7849        {
7850            self.advance(); // LATERAL
7851            self.advance(); // jsonb_each_text
7852            self.advance(); // (
7853            let arg = self.parse_expr(0)?;
7854            if !matches!(self.peek(), Token::RParen) {
7855                return Err(self.err(alloc::format!(
7856                    "expected ')' after LATERAL jsonb_each_text() argument, got {:?}",
7857                    self.peek()
7858                )));
7859            }
7860            self.advance();
7861            let (alias_ident, column_aliases) = self.parse_optional_alias_with_columns();
7862            let alias = alias_ident
7863                .clone()
7864                .unwrap_or_else(|| "jsonb_each_text".to_string());
7865            // Synthesise: SELECT __srf__.key AS <key_alias>, __srf__.value AS <value_alias>
7866            //               FROM jsonb_each_text(<arg>) AS __srf__
7867            // PG's `AS kv(key, value)` column-alias list maps
7868            // positions to names; default to (key, value) when
7869            // omitted (matching the SRF's natural column names).
7870            let srf_alias = "__srf__".to_string();
7871            let key_alias = column_aliases
7872                .first()
7873                .cloned()
7874                .unwrap_or_else(|| "key".to_string());
7875            let value_alias = column_aliases
7876                .get(1)
7877                .cloned()
7878                .unwrap_or_else(|| "value".to_string());
7879            let inner_select = crate::ast::SelectStatement {
7880                ctes: Vec::new(),
7881                distinct: false,
7882                items: alloc::vec![
7883                    crate::ast::SelectItem::Expr {
7884                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7885                            qualifier: Some(srf_alias.clone()),
7886                            name: "key".to_string(),
7887                        }),
7888                        alias: Some(key_alias),
7889                    },
7890                    crate::ast::SelectItem::Expr {
7891                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7892                            qualifier: Some(srf_alias.clone()),
7893                            name: "value".to_string(),
7894                        }),
7895                        alias: Some(value_alias),
7896                    },
7897                ],
7898                from: Some(crate::ast::FromClause {
7899                    primary: TableRef {
7900                        name: srf_alias.clone(),
7901                        alias: Some(srf_alias.clone()),
7902                        as_of_segment: None,
7903                        unnest_expr: None,
7904                        unnest_column_aliases: Vec::new(),
7905                        generate_series_args: None,
7906                        lateral_subquery: None,
7907                        jsonb_each_text_arg: Some(Box::new(arg)),
7908                    },
7909                    joins: Vec::new(),
7910                }),
7911                where_: None,
7912                group_by: None,
7913                group_by_all: false,
7914                having: None,
7915                unions: Vec::new(),
7916                order_by: Vec::new(),
7917                limit: None,
7918                offset: None,
7919                limit_with_ties: false,
7920            };
7921            return Ok(TableRef {
7922                name: alias.clone(),
7923                alias: Some(alias),
7924                as_of_segment: None,
7925                unnest_expr: None,
7926                unnest_column_aliases: Vec::new(),
7927                generate_series_args: None,
7928                lateral_subquery: Some(Box::new(inner_select)),
7929                jsonb_each_text_arg: None,
7930            });
7931        }
7932        // v7.37.43-T4.5 — bare `CROSS JOIN jsonb_each_text(t.col)`
7933        // without an explicit `LATERAL` keyword is the same shape
7934        // PG accepts (SRF naturally licences lateral correlation).
7935        // We mirror the LATERAL rewrite when the argument syntactic-
7936        // ally references an outer column (Column { qualifier:
7937        // Some(_), … }). For simplicity we apply the rewrite
7938        // whenever the SRF directly follows JOIN/CROSS JOIN/comma
7939        // in the FROM-list — caller-side join parsing positions
7940        // this peek correctly.
7941        // (Implementation note: detection lives below; the LATERAL
7942        // branch above already covers the explicit form; the bare
7943        // form falls through to the plain SRF arm and the engine
7944        // treats it as a constant-arg SRF if no outer reference is
7945        // present.)
7946        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7947        // table. Detect at the head so it claims precedence over
7948        // every other table-ref shape (unnest / generate_series /
7949        // bare ident); the lateral subquery itself follows the
7950        // regular SELECT grammar.
7951        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7952            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7953        {
7954            self.advance(); // LATERAL
7955            self.advance(); // (
7956            // Parse the inner SELECT.
7957            let inner = match self.parse_one_statement()? {
7958                Statement::Select(s) => s,
7959                other => {
7960                    return Err(self.err(alloc::format!(
7961                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7962                    )));
7963                }
7964            };
7965            if !matches!(self.peek(), Token::RParen) {
7966                return Err(self.err(alloc::format!(
7967                    "expected ')' after LATERAL subquery, got {:?}",
7968                    self.peek()
7969                )));
7970            }
7971            self.advance();
7972            let alias_ident = self.parse_optional_alias();
7973            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7974            return Ok(TableRef {
7975                name,
7976                alias: alias_ident,
7977                as_of_segment: None,
7978                unnest_expr: None,
7979                unnest_column_aliases: Vec::new(),
7980                generate_series_args: None,
7981                lateral_subquery: Some(Box::new(inner)),
7982                jsonb_each_text_arg: None,
7983            });
7984        }
7985        // v7.37.43-T4.5 — `jsonb_each_text(<expr>)` set-returning
7986        // function as a FROM item. Emits one row per (key, value)
7987        // pair in the JSONB object argument as TEXT columns. May
7988        // be wrapped in CROSS JOIN LATERAL when the argument
7989        // references a preceding FROM item (sentori migration
7990        // 0067 backfill shape: `CROSS JOIN LATERAL
7991        // jsonb_each_text(t.json_col) AS kv(key, value)`).
7992        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("jsonb_each_text"))
7993            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7994        {
7995            self.advance(); // jsonb_each_text
7996            self.advance(); // (
7997            let arg = self.parse_expr(0)?;
7998            if !matches!(self.peek(), Token::RParen) {
7999                return Err(self.err(alloc::format!(
8000                    "expected ')' after jsonb_each_text() argument, got {:?}",
8001                    self.peek()
8002                )));
8003            }
8004            self.advance();
8005            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
8006            let name = alias_ident
8007                .clone()
8008                .unwrap_or_else(|| "jsonb_each_text".to_string());
8009            return Ok(TableRef {
8010                name,
8011                alias: alias_ident,
8012                as_of_segment: None,
8013                unnest_expr: None,
8014                unnest_column_aliases: Vec::new(),
8015                generate_series_args: None,
8016                lateral_subquery: None,
8017                jsonb_each_text_arg: Some(Box::new(arg)),
8018            });
8019        }
8020        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
8021        // source. Detect at the head before the bare-ident fallback;
8022        // unnest is not a reserved token.
8023        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
8024            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
8025        {
8026            self.advance(); // unnest
8027            self.advance(); // (
8028            let expr = self.parse_expr(0)?;
8029            if !matches!(self.peek(), Token::RParen) {
8030                return Err(self.err(alloc::format!(
8031                    "expected ')' after unnest() argument, got {:?}",
8032                    self.peek()
8033                )));
8034            }
8035            self.advance();
8036            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
8037            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
8038            return Ok(TableRef {
8039                name,
8040                alias: alias_ident,
8041                as_of_segment: None,
8042                unnest_expr: Some(Box::new(expr)),
8043                unnest_column_aliases,
8044                generate_series_args: None,
8045                lateral_subquery: None,
8046                jsonb_each_text_arg: None,
8047            });
8048        }
8049        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
8050        // [, step])` set-returning source. Same shape as unnest:
8051        // detect at the head, parse the comma-separated arg list,
8052        // dispatch downstream through the engine's set-returning
8053        // path. Supports integer triplets (mailrs's `WITH row_no AS
8054        // (SELECT * FROM generate_series(1, N))` pattern) and
8055        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
8056        // date-range iteration pattern, which pre-3.10 had no
8057        // direct equivalent in SPG).
8058        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
8059            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
8060        {
8061            self.advance(); // generate_series
8062            self.advance(); // (
8063            let mut args: Vec<Expr> = Vec::new();
8064            loop {
8065                args.push(self.parse_expr(0)?);
8066                if matches!(self.peek(), Token::Comma) {
8067                    self.advance();
8068                    continue;
8069                }
8070                break;
8071            }
8072            if !matches!(self.peek(), Token::RParen) {
8073                return Err(self.err(alloc::format!(
8074                    "expected ')' after generate_series() arguments, got {:?}",
8075                    self.peek()
8076                )));
8077            }
8078            self.advance();
8079            if args.len() < 2 || args.len() > 3 {
8080                return Err(self.err(alloc::format!(
8081                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
8082                    args.len()
8083                )));
8084            }
8085            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
8086            let name = alias_ident
8087                .clone()
8088                .unwrap_or_else(|| "generate_series".to_string());
8089            return Ok(TableRef {
8090                name,
8091                alias: alias_ident,
8092                as_of_segment: None,
8093                unnest_expr: None,
8094                unnest_column_aliases: Vec::new(),
8095                generate_series_args: Some(args),
8096                lateral_subquery: None,
8097                jsonb_each_text_arg: None,
8098            });
8099        }
8100        // v7.16.2 — preserve information_schema / pg_catalog
8101        // qualifiers (mailrs round-10 A.3). The generic
8102        // `expect_ident_like` strip silently drops the schema;
8103        // we want the engine to recognise these PG meta tables
8104        // and synthesise rows from the live catalog. Produce a
8105        // synthetic name (`__spg_info_columns` etc.) so the
8106        // engine's SELECT-side router can dispatch without
8107        // clashing with any user-defined `columns` table.
8108        let name = if let Some(synth) = self.try_peek_meta_qualified() {
8109            synth
8110        } else if let Some(synth) = self.try_peek_meta_bare() {
8111            synth
8112        } else {
8113            self.expect_ident_like()?
8114        };
8115        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
8116        // time-travel clause. Parse BEFORE the alias so the
8117        // alias can still ride at the tail (`tbl AS OF SEGMENT
8118        // '5' alias`). `AS` is a reserved keyword token, while
8119        // `OF` and `SEGMENT` are bare idents.
8120        let as_of_segment = if matches!(self.peek(), Token::As)
8121            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
8122        {
8123            self.advance(); // AS
8124            self.advance(); // OF
8125            let kw = match self.peek().clone() {
8126                Token::Ident(s) | Token::QuotedIdent(s) => s,
8127                other => {
8128                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
8129                }
8130            };
8131            if !kw.eq_ignore_ascii_case("segment") {
8132                return Err(self.err(format!(
8133                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
8134                )));
8135            }
8136            self.advance();
8137            // Segment id literal — accept either a string or
8138            // integer for operator ergonomics.
8139            let id = match self.advance() {
8140                Token::String(s) => s
8141                    .parse::<u32>()
8142                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
8143                Token::Integer(n) => u32::try_from(n)
8144                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
8145                other => {
8146                    return Err(self.err(format!(
8147                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
8148                    )));
8149                }
8150            };
8151            Some(id)
8152        } else {
8153            None
8154        };
8155        let alias = self.parse_optional_alias();
8156        Ok(TableRef {
8157            name,
8158            alias,
8159            as_of_segment,
8160            unnest_expr: None,
8161            unnest_column_aliases: Vec::new(),
8162            generate_series_args: None,
8163            lateral_subquery: None,
8164            jsonb_each_text_arg: None,
8165        })
8166    }
8167
8168    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
8169    /// but also accepts `AS alias(col [, col, …])` — the
8170    /// PG-standard table-function column-list form. The column
8171    /// list is only honoured when paired with `UNNEST(...)` in
8172    /// the parent; other call sites currently discard it.
8173    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
8174        let alias = self.parse_optional_alias();
8175        if alias.is_none() {
8176            return (None, Vec::new());
8177        }
8178        let mut cols: Vec<String> = Vec::new();
8179        if matches!(self.peek(), Token::LParen) {
8180            self.advance();
8181            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
8182                self.advance();
8183                cols.push(s);
8184                if matches!(self.peek(), Token::Comma) {
8185                    self.advance();
8186                    continue;
8187                }
8188                break;
8189            }
8190            if matches!(self.peek(), Token::RParen) {
8191                self.advance();
8192            }
8193        }
8194        (alias, cols)
8195    }
8196
8197    /// FROM-clause: a primary table reference plus zero-or-more joined
8198    /// peers expressed via either `, <table>` (cross-product, no ON) or
8199    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
8200    /// the join list flat (left-associative nested-loop semantics).
8201    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
8202        let primary = self.parse_table_ref()?;
8203        let mut joins = Vec::new();
8204        loop {
8205            // `, <table>` — cross-product with no ON.
8206            if matches!(self.peek(), Token::Comma) {
8207                self.advance();
8208                let table = self.parse_table_ref()?;
8209                joins.push(FromJoin {
8210                    kind: JoinKind::Cross,
8211                    table,
8212                    on: None,
8213                });
8214                continue;
8215            }
8216            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
8217            // CROSS JOIN, and bare JOIN (defaults to INNER).
8218            let kind =
8219                match self.peek() {
8220                    Token::Inner => {
8221                        self.advance();
8222                        if !matches!(self.peek(), Token::Join) {
8223                            return Err(self
8224                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
8225                        }
8226                        self.advance();
8227                        JoinKind::Inner
8228                    }
8229                    Token::Left => {
8230                        self.advance();
8231                        if matches!(self.peek(), Token::Outer) {
8232                            self.advance();
8233                        }
8234                        if !matches!(self.peek(), Token::Join) {
8235                            return Err(self.err(format!(
8236                                "expected JOIN after LEFT [OUTER], got {:?}",
8237                                self.peek()
8238                            )));
8239                        }
8240                        self.advance();
8241                        JoinKind::Left
8242                    }
8243                    Token::Cross => {
8244                        self.advance();
8245                        if !matches!(self.peek(), Token::Join) {
8246                            return Err(self
8247                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
8248                        }
8249                        self.advance();
8250                        JoinKind::Cross
8251                    }
8252                    Token::Join => {
8253                        self.advance();
8254                        JoinKind::Inner
8255                    }
8256                    _ => break,
8257                };
8258            let table = self.parse_table_ref()?;
8259            // v7.37.7 C.1 — USING (col_list) sugar. Desugars to
8260            // `prev_table.col1 = table.col1 AND prev_table.col2 = table.col2 …`
8261            // where prev_table is the most-recent left-side table
8262            // (the previous join's table if any, else the FROM primary).
8263            // PG semantics around column merging are richer (USING'd
8264            // cols become deduplicated single output columns); for
8265            // sugar purposes the predicate-only form covers the
8266            // baseline corpus shape and chained `… JOIN x USING (k)
8267            // JOIN y USING (k)` calls.
8268            let using_match = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using"));
8269            let on = if matches!(self.peek(), Token::On) {
8270                self.advance();
8271                Some(self.parse_expr(0)?)
8272            } else if using_match {
8273                self.advance();
8274                if !matches!(self.peek(), Token::LParen) {
8275                    return Err(self.err(format!(
8276                        "expected '(' after USING, got {:?}",
8277                        self.peek()
8278                    )));
8279                }
8280                self.advance();
8281                let mut cols: Vec<String> = Vec::new();
8282                loop {
8283                    match self.peek().clone() {
8284                        Token::Ident(s) | Token::QuotedIdent(s) => {
8285                            self.advance();
8286                            cols.push(s);
8287                        }
8288                        other => {
8289                            return Err(self.err(format!(
8290                                "expected column name inside USING (…), got {other:?}"
8291                            )));
8292                        }
8293                    }
8294                    match self.peek() {
8295                        Token::Comma => {
8296                            self.advance();
8297                            continue;
8298                        }
8299                        Token::RParen => {
8300                            self.advance();
8301                            break;
8302                        }
8303                        other => {
8304                            return Err(self.err(format!(
8305                                "expected ',' or ')' inside USING (…), got {other:?}"
8306                            )));
8307                        }
8308                    }
8309                }
8310                if cols.is_empty() {
8311                    return Err(self.err("USING (…) requires at least one column".to_string()));
8312                }
8313                // Pick the left-side alias: prev join's table if any,
8314                // else FROM primary. Use alias when present, else
8315                // table name (PG-equivalent qualifier).
8316                let left_qual: String = joins
8317                    .last()
8318                    .map(|j| {
8319                        j.table
8320                            .alias
8321                            .clone()
8322                            .unwrap_or_else(|| j.table.name.clone())
8323                    })
8324                    .unwrap_or_else(|| {
8325                        primary
8326                            .alias
8327                            .clone()
8328                            .unwrap_or_else(|| primary.name.clone())
8329                    });
8330                let right_qual = table
8331                    .alias
8332                    .clone()
8333                    .unwrap_or_else(|| table.name.clone());
8334                let mut iter = cols.into_iter().map(|c| Expr::Binary {
8335                    lhs: alloc::boxed::Box::new(Expr::Column(crate::ast::ColumnName {
8336                        qualifier: Some(left_qual.clone()),
8337                        name: c.clone(),
8338                    })),
8339                    op: crate::ast::BinOp::Eq,
8340                    rhs: alloc::boxed::Box::new(Expr::Column(crate::ast::ColumnName {
8341                        qualifier: Some(right_qual.clone()),
8342                        name: c,
8343                    })),
8344                });
8345                let first = iter.next().expect("at least one col");
8346                Some(iter.fold(first, |acc, pred| Expr::Binary {
8347                    lhs: alloc::boxed::Box::new(acc),
8348                    op: crate::ast::BinOp::And,
8349                    rhs: alloc::boxed::Box::new(pred),
8350                }))
8351            } else if kind == JoinKind::Cross {
8352                None
8353            } else {
8354                return Err(self.err(format!(
8355                    "expected ON or USING after {:?} JOIN, got {:?}",
8356                    kind,
8357                    self.peek()
8358                )));
8359            };
8360            joins.push(FromJoin { kind, table, on });
8361        }
8362        Ok(FromClause { primary, joins })
8363    }
8364
8365    /// Optional alias after an expression or table:
8366    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
8367    /// accepted (PG-style implicit alias). Returns `None` if the next token
8368    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
8369    fn parse_optional_alias(&mut self) -> Option<String> {
8370        if matches!(self.peek(), Token::As) {
8371            self.advance();
8372            // After AS, the next token MUST be an identifier-like — if not,
8373            // we still return None and let the caller surface the error on the
8374            // next expectation. v0.2 keeps the alias path forgiving; the
8375            // corpus tests don't exercise the malformed case.
8376            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
8377                return self.expect_ident_like().ok();
8378            }
8379            return None;
8380        }
8381        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
8382        // grammar reserves a long list of follow-keywords from the
8383        // alias slot. SPG's bareword approximation: skip a small
8384        // set of idents that would otherwise be swallowed as the
8385        // table alias and break trailing clauses like CREATE
8386        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
8387        // CONFLICT WHERE shapes.
8388        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8389            if is_alias_stopword(s) {
8390                return None;
8391            }
8392            return self.expect_ident_like().ok();
8393        }
8394        None
8395    }
8396
8397    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
8398    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8399        // v7.30.2 (mailrs round-25 ask 2) — nesting budget: a parse
8400        // error beats a stack overflow (an overflow aborts the
8401        // embedding host process).
8402        self.enter_nested()?;
8403        let r = self.parse_expr_inner(min_prec);
8404        self.nest_depth -= 1;
8405        r
8406    }
8407
8408    fn parse_expr_inner(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8409        let mut lhs = self.parse_unary()?;
8410        let mut chain_len = 0usize;
8411        while let Some((op, prec)) = binop_from(self.peek()) {
8412            if prec < min_prec {
8413                break;
8414            }
8415            // v7.30.2 (mailrs round-25 ask 2) — the chain builds
8416            // iteratively but evaluates and drops recursively;
8417            // depth beyond the budget overflows worker stacks.
8418            chain_len += 1;
8419            if chain_len > MAX_BINARY_CHAIN {
8420                return Err(self.err(alloc::format!(
8421                    "more than {MAX_BINARY_CHAIN} chained binary operators; rewrite long OR-equality chains as IN (…)"
8422                )));
8423            }
8424            self.advance();
8425            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
8426            // ANY is a bare ident; ALL is a reserved Token. Both
8427            // require an immediate `(` to disambiguate from
8428            // identifier columns named `any` / `all`.
8429            let any_kind = match self.peek() {
8430                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
8431                    Some(false)
8432                }
8433                Token::Ident(s) | Token::QuotedIdent(s)
8434                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
8435                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
8436                {
8437                    Some(s.eq_ignore_ascii_case("any"))
8438                }
8439                _ => None,
8440            };
8441            if let Some(is_any) = any_kind {
8442                self.advance(); // ident
8443                self.advance(); // (
8444                let arr = self.parse_expr(0)?;
8445                if !matches!(self.peek(), Token::RParen) {
8446                    return Err(self.err(alloc::format!(
8447                        "expected ')' after ANY/ALL argument, got {:?}",
8448                        self.peek()
8449                    )));
8450                }
8451                self.advance();
8452                lhs = Expr::AnyAll {
8453                    expr: Box::new(lhs),
8454                    op,
8455                    array: Box::new(arr),
8456                    is_any,
8457                };
8458                continue;
8459            }
8460            let rhs = self.parse_expr(prec + 1)?;
8461            lhs = Expr::Binary {
8462                lhs: Box::new(lhs),
8463                op,
8464                rhs: Box::new(rhs),
8465            };
8466        }
8467        Ok(lhs)
8468    }
8469
8470    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
8471        match self.peek() {
8472            Token::Not => {
8473                self.advance();
8474                // NOT sits between AND (2) and comparisons (4) — bind everything
8475                // ≥3, which leaves AND/OR outside.
8476                let e = self.parse_expr(3)?;
8477                Ok(Expr::Unary {
8478                    op: UnOp::Not,
8479                    expr: Box::new(e),
8480                })
8481            }
8482            Token::Minus => {
8483                self.advance();
8484                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
8485                // `<->` slotted into 5 and arithmetic shifted up).
8486                let e = self.parse_expr(8)?;
8487                Ok(Expr::Unary {
8488                    op: UnOp::Neg,
8489                    expr: Box::new(e),
8490                })
8491            }
8492            Token::Tilde => {
8493                self.advance();
8494                // Bitwise NOT binds like unary minus.
8495                let e = self.parse_expr(8)?;
8496                Ok(Expr::Unary {
8497                    op: UnOp::BitNot,
8498                    expr: Box::new(e),
8499                })
8500            }
8501            _ => self.parse_atom(),
8502        }
8503    }
8504
8505    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
8506        let tok_pos = self.pos;
8507        match self.advance() {
8508            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
8509            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
8510            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
8511            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
8512            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
8513            Token::Null => Ok(Expr::Literal(Literal::Null)),
8514            // v6.1.1 — `$N` placeholder. The actual Value lookup
8515            // happens in the engine eval path against the prepared-
8516            // statement bind buffer.
8517            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
8518            Token::LParen => {
8519                // v4.10: `(SELECT ...)` in expression position is a
8520                // scalar subquery; otherwise it's a parenthesised
8521                // expression. Peek for SELECT keyword to dispatch.
8522                if matches!(self.peek(), Token::Select) {
8523                    let inner = self.parse_select_stmt()?;
8524                    match self.advance() {
8525                        Token::RParen => {
8526                            let Statement::Select(s) = inner else {
8527                                unreachable!("parse_select_stmt returns Select")
8528                            };
8529                            Ok(Expr::ScalarSubquery(Box::new(s)))
8530                        }
8531                        other => Err(ParseError {
8532                            message: format!("expected ')' after scalar subquery, got {other:?}"),
8533                            token_pos: self.pos.saturating_sub(1),
8534                        }),
8535                    }
8536                } else {
8537                    let e = self.parse_expr(0)?;
8538                    match self.advance() {
8539                        Token::RParen => Ok(e),
8540                        other => Err(ParseError {
8541                            message: format!("expected ')', got {other:?}"),
8542                            token_pos: self.pos.saturating_sub(1),
8543                        }),
8544                    }
8545                }
8546            }
8547            Token::LBracket => self.parse_vector_literal_body(),
8548            Token::Extract => self.parse_extract_atom(),
8549            Token::Interval => self.parse_interval_atom(),
8550            // `LEFT` is a reserved-keyword token because the
8551            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
8552            // When `left` is followed by `(` we're in expression
8553            // position calling the PG `left(string, n)` function;
8554            // rebuild the AST as a regular function call so the
8555            // engine's apply_function dispatch picks it up.
8556            Token::Left if matches!(self.peek(), Token::LParen) => {
8557                self.advance(); // (
8558                let mut args = Vec::new();
8559                if !matches!(self.peek(), Token::RParen) {
8560                    loop {
8561                        args.push(self.parse_expr(0)?);
8562                        match self.peek() {
8563                            Token::Comma => {
8564                                self.advance();
8565                            }
8566                            Token::RParen => break,
8567                            other => {
8568                                return Err(self.err(alloc::format!(
8569                                    "expected ',' or ')' in left() args, got {other:?}"
8570                                )));
8571                            }
8572                        }
8573                    }
8574                }
8575                self.advance(); // )
8576                Ok(Expr::FunctionCall {
8577                    name: "left".into(),
8578                    args,
8579                })
8580            }
8581            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
8582            // token; we match on the bare ident. NOT is a token
8583            // (consumed in the comparison rung), but `EXISTS (...)`
8584            // at the top of an expression starts here.
8585            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
8586                self.parse_exists_atom(false)
8587            }
8588            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
8589            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
8590            // CASE is a bare ident; we dispatch on lowercase match.
8591            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
8592                self.parse_case_atom()
8593            }
8594            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
8595            // is not a reserved token; we match by case-insensitive
8596            // ident. The opening `[` must follow immediately.
8597            Token::Ident(s) | Token::QuotedIdent(s)
8598                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
8599            {
8600                self.advance(); // consume `[`
8601                let mut items: Vec<Expr> = Vec::new();
8602                if !matches!(self.peek(), Token::RBracket) {
8603                    loop {
8604                        items.push(self.parse_expr(0)?);
8605                        match self.peek() {
8606                            Token::Comma => {
8607                                self.advance();
8608                            }
8609                            Token::RBracket => break,
8610                            other => {
8611                                return Err(self.err(alloc::format!(
8612                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
8613                                )));
8614                            }
8615                        }
8616                    }
8617                }
8618                self.advance(); // consume `]`
8619                Ok(Expr::Array(items))
8620            }
8621            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
8622            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
8623            // We special-case before the generic ident dispatch so
8624            // the AGAINST clause never reaches the function-call
8625            // loop (which would mis-read `(cols) AGAINST` as a
8626            // call with no trailing modifier). The shape is
8627            // rewritten to a Boolean OR over per-column
8628            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8629            // term)` so the existing FTS evaluator handles
8630            // semantics — the fulltext-GIN built at CREATE TABLE
8631            // time is currently a "real index that survives dump
8632            // round-trip"; the planner hook that actually uses
8633            // it for posting-list intersection lands in a later
8634            // sub-phase (Phase 2.2b) without touching this surface.
8635            Token::Ident(s) | Token::QuotedIdent(s)
8636                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
8637            {
8638                self.parse_match_against_atom()
8639            }
8640            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
8641            // v7.37.43-T4 — PG-unreserved keywords are legal column /
8642            // alias names in expression context too. `release` appears
8643            // in sentori `0003_partition_events.sql` as both a column
8644            // reference (SELECT … release …) and an INSERT column list
8645            // entry. Mirrors `expect_ident_like`'s expansion of the
8646            // identifier set.
8647            other if unreserved_keyword_text(&other).is_some() => {
8648                let s = unreserved_keyword_text(&other).unwrap();
8649                self.finish_ident_atom(s)
8650            }
8651            other => Err(ParseError {
8652                message: format!("unexpected token {other:?} in expression"),
8653                token_pos: tok_pos,
8654            }),
8655        }
8656        // After parsing the atom, fold any postfix `::vector` casts.
8657        .and_then(|atom| self.finish_postfix_casts(atom))
8658    }
8659
8660    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
8661    /// Both bind tighter than any binary op.
8662    /// Shared cast-target parser for postfix `::TYPE` and the
8663    /// standard `CAST(expr AS TYPE)` form (v7.25, round-17).
8664    fn parse_cast_target(&mut self) -> Result<CastTarget, ParseError> {
8665        let target = match self.advance() {
8666            Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
8667                "int" | "integer" | "int4" => {
8668                    if matches!(self.peek(), Token::LBracket)
8669                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8670                    {
8671                        self.advance();
8672                        self.advance();
8673                        CastTarget::IntArray
8674                    } else {
8675                        CastTarget::Int
8676                    }
8677                }
8678                "bigint" | "int8" => {
8679                    if matches!(self.peek(), Token::LBracket)
8680                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8681                    {
8682                        self.advance();
8683                        self.advance();
8684                        CastTarget::BigIntArray
8685                    } else {
8686                        CastTarget::BigInt
8687                    }
8688                }
8689                "float" | "double" | "real" => CastTarget::Float,
8690                "text" => {
8691                    // v7.10.11 — `::TEXT[]` widens to TextArray.
8692                    if matches!(self.peek(), Token::LBracket)
8693                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8694                    {
8695                        self.advance();
8696                        self.advance();
8697                        CastTarget::TextArray
8698                    } else {
8699                        CastTarget::Text
8700                    }
8701                }
8702                "bool" | "boolean" => CastTarget::Bool,
8703                "vector" => CastTarget::Vector,
8704                "date" => CastTarget::Date,
8705                "timestamp" | "datetime" => CastTarget::Timestamp,
8706                "timestamptz" => CastTarget::Timestamptz,
8707                "interval" => CastTarget::Interval,
8708                "json" => CastTarget::Json,
8709                "jsonb" => CastTarget::Jsonb,
8710                "regtype" => CastTarget::RegType,
8711                "regclass" => CastTarget::RegClass,
8712                // v7.12.0 — `::tsvector` / `::tsquery`.
8713                // Engine decodes the LHS text via the PG
8714                // external form parser.
8715                "tsvector" => CastTarget::TsVector,
8716                "tsquery" => CastTarget::TsQuery,
8717                // v7.17.0 — `::uuid`. Engine decodes the LHS
8718                // text via `spg_storage::parse_uuid_str`.
8719                "uuid" => CastTarget::Uuid,
8720                // v7.18 — `::bytea`. Engine decodes the LHS
8721                // text via the PG hex form (`'\xdeadbeef'`)
8722                // or escape form (`'\\x05\\x00'`). Closes
8723                // mailrs D-pre #3 reverse-acceptance gap.
8724                "bytea" => CastTarget::Bytea,
8725                // v7.37.5 ship triage — generic typed-cast escape.
8726                // Anything the long-tail PG type ident table knows
8727                // about(network/bit/geometry/multirange/etc.)flows
8728                // through `CastTarget::Named(canonical)`; the engine
8729                // resolves via `column_type_to_data_type` and dispatches
8730                // through the typed `coerce_value` path. Truly
8731                // unrecognised idents still hit the error arm below
8732                // because the engine rejects them.
8733                other => {
8734                    // Optional `(N[, M])` precision args — `::numeric(10,2)`,
8735                    // `::varchar(255)`, etc. Capture into the canonical
8736                    // `name(p,s)` form so `type_name_to_data_type` can
8737                    // reconstruct the `DataType::Numeric { precision,
8738                    // scale }` (and similar param-carrying types).
8739                    let mut name = other.to_string();
8740                    if matches!(self.peek(), Token::LParen) {
8741                        let mut buf = alloc::string::String::from("(");
8742                        let mut depth = 0usize;
8743                        loop {
8744                            match self.advance() {
8745                                Token::LParen => {
8746                                    depth += 1;
8747                                    if depth > 1 {
8748                                        buf.push('(');
8749                                    }
8750                                }
8751                                Token::RParen => {
8752                                    depth -= 1;
8753                                    if depth == 0 {
8754                                        buf.push(')');
8755                                        break;
8756                                    }
8757                                    buf.push(')');
8758                                }
8759                                Token::Comma => buf.push(','),
8760                                Token::Integer(n) => buf.push_str(&alloc::format!("{n}")),
8761                                Token::Eof => break,
8762                                _ => {}
8763                            }
8764                        }
8765                        name.push_str(&buf);
8766                    }
8767                    // Optional postfix `[]` widens to the array form —
8768                    // `::BOOL[]`, `::NUMERIC[]`, `::SMALLINT[]`, etc.
8769                    // The engine's `type_name_to_data_type` recognises
8770                    // the canonical `<ty>_array` form.
8771                    if matches!(self.peek(), Token::LBracket)
8772                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8773                    {
8774                        self.advance();
8775                        self.advance();
8776                        name.push_str("_array");
8777                    }
8778                    CastTarget::Named(name)
8779                }
8780            },
8781            Token::Interval => CastTarget::Interval,
8782            other => {
8783                return Err(ParseError {
8784                    message: format!("expected type ident after `::`, got {other:?}"),
8785                    token_pos: self.pos.saturating_sub(1),
8786                });
8787            }
8788        };
8789        // v7.37.5 ship triage — postfix `[]` widens a scalar cast
8790        // target to its array sibling. Closed-enum arms (Bool /
8791        // SmallInt / Numeric / Float / Date / …) didn't carry the
8792        // explicit widening that Text / Int / BigInt did, so
8793        // `::BOOL[]` / `::NUMERIC[]` etc. surfaced as a parse
8794        // error. The widening here mirrors the per-arm Text /
8795        // Int / BigInt logic above + folds the new ζ-A first-class
8796        // types through `CastTarget::Named("<ty>_array")`.
8797        if matches!(self.peek(), Token::LBracket)
8798            && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8799        {
8800            let widened = match &target {
8801                CastTarget::Bool => Some(CastTarget::Named("bool_array".to_string())),
8802                CastTarget::Date => Some(CastTarget::Named("date_array".to_string())),
8803                CastTarget::Timestamp | CastTarget::Timestamptz => {
8804                    Some(CastTarget::Named("timestamptz_array".to_string()))
8805                }
8806                CastTarget::Uuid => Some(CastTarget::Named("uuid_array".to_string())),
8807                CastTarget::Json | CastTarget::Jsonb => {
8808                    Some(CastTarget::Named("jsonb_array".to_string()))
8809                }
8810                CastTarget::Bytea => Some(CastTarget::Named("bytea_array".to_string())),
8811                CastTarget::Interval => Some(CastTarget::Named("interval_array".to_string())),
8812                CastTarget::Float => Some(CastTarget::Named("float_array".to_string())),
8813                CastTarget::Named(name) => {
8814                    let mut a = name.clone();
8815                    a.push_str("_array");
8816                    Some(CastTarget::Named(a))
8817                }
8818                // Int / BigInt / Text / Vector / TsVector / TsQuery /
8819                // RegType / RegClass / TextArray / IntArray /
8820                // BigIntArray already finalised — leave as is.
8821                _ => None,
8822            };
8823            if let Some(w) = widened {
8824                self.advance();
8825                self.advance();
8826                return Ok(w);
8827            }
8828        }
8829        Ok(target)
8830    }
8831
8832    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
8833        loop {
8834            if matches!(self.peek(), Token::DoubleColon) {
8835                self.advance();
8836                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
8837                // target set to include INTERVAL (reserved Token),
8838                // TIMESTAMPTZ, and PG catalog regtype / regclass.
8839                // mailrs follow-up H3a + H3b.
8840                let target = self.parse_cast_target()?;
8841                expr = Expr::Cast {
8842                    expr: Box::new(expr),
8843                    target,
8844                };
8845                continue;
8846            }
8847            if matches!(self.peek(), Token::Is) {
8848                self.advance();
8849                let negated = if matches!(self.peek(), Token::Not) {
8850                    self.advance();
8851                    true
8852                } else {
8853                    false
8854                };
8855                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
8856                // mailrs pg_dump.
8857                if matches!(self.peek(), Token::Distinct) {
8858                    self.advance();
8859                    if !matches!(self.peek(), Token::From) {
8860                        return Err(self.err(format!(
8861                            "expected FROM after IS{} DISTINCT, got {:?}",
8862                            if negated { " NOT" } else { "" },
8863                            self.peek()
8864                        )));
8865                    }
8866                    self.advance();
8867                    // Right-hand side: parse at the same precedence
8868                    // tier as comparison so `x IS DISTINCT FROM a + b`
8869                    // groups as `x IS DISTINCT FROM (a + b)`.
8870                    let rhs = self.parse_expr(20)?;
8871                    let op = if negated {
8872                        BinOp::IsNotDistinctFrom
8873                    } else {
8874                        BinOp::IsDistinctFrom
8875                    };
8876                    expr = Expr::Binary {
8877                        op,
8878                        lhs: Box::new(expr),
8879                        rhs: Box::new(rhs),
8880                    };
8881                    continue;
8882                }
8883                if !matches!(self.peek(), Token::Null) {
8884                    return Err(self.err(format!(
8885                        "expected NULL or DISTINCT after IS{}, got {:?}",
8886                        if negated { " NOT" } else { "" },
8887                        self.peek()
8888                    )));
8889                }
8890                self.advance();
8891                expr = Expr::IsNull {
8892                    expr: Box::new(expr),
8893                    negated,
8894                };
8895                continue;
8896            }
8897            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
8898            // Look one token ahead so a stray `NOT` not followed by any of
8899            // these flows through to the early return below untouched.
8900            let negated = if matches!(self.peek(), Token::Not) {
8901                let next = self.tokens.get(self.pos + 1);
8902                matches!(next, Some(Token::Between | Token::In | Token::Like))
8903                    || matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("ilike"))
8904            } else {
8905                false
8906            };
8907            if negated {
8908                self.advance();
8909            }
8910            if matches!(self.peek(), Token::Between) {
8911                expr = self.parse_between_tail(expr, negated)?;
8912                continue;
8913            }
8914            if matches!(self.peek(), Token::In) {
8915                expr = self.parse_in_tail(expr, negated)?;
8916                continue;
8917            }
8918            if matches!(self.peek(), Token::Like) {
8919                self.advance();
8920                // Pattern at the same precedence as other comparison RHSes —
8921                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
8922                let pattern = self.parse_expr(5)?;
8923                expr = Expr::Like {
8924                    expr: Box::new(expr),
8925                    pattern: Box::new(pattern),
8926                    negated,
8927                    case_insensitive: false,
8928                };
8929                continue;
8930            }
8931            // v7.25 (round-17) — ILIKE: case-insensitive LIKE. The
8932            // keyword reaches us as a plain identifier.
8933            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("ilike")) {
8934                self.advance();
8935                let pattern = self.parse_expr(5)?;
8936                expr = Expr::Like {
8937                    expr: Box::new(expr),
8938                    pattern: Box::new(pattern),
8939                    negated,
8940                    case_insensitive: true,
8941                };
8942                continue;
8943            }
8944            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
8945            // returns NULL for out-of-range. Multiple subscripts
8946            // chain: `a[i][j]` parses left-to-right.
8947            if matches!(self.peek(), Token::LBracket) {
8948                self.advance();
8949                let index = self.parse_expr(0)?;
8950                if !matches!(self.peek(), Token::RBracket) {
8951                    return Err(self.err(alloc::format!(
8952                        "expected ']' after array index, got {:?}",
8953                        self.peek()
8954                    )));
8955                }
8956                self.advance();
8957                expr = Expr::ArraySubscript {
8958                    target: Box::new(expr),
8959                    index: Box::new(index),
8960                };
8961                continue;
8962            }
8963            return Ok(expr);
8964        }
8965    }
8966
8967    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
8968    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
8969    /// `AND` is not swallowed.
8970    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8971        self.advance(); // BETWEEN
8972        let low = self.parse_expr(5)?;
8973        if !matches!(self.peek(), Token::And) {
8974            return Err(self.err(format!(
8975                "expected AND after BETWEEN low bound, got {:?}",
8976                self.peek()
8977            )));
8978        }
8979        self.advance();
8980        let high = self.parse_expr(5)?;
8981        let target = Box::new(expr);
8982        let combined = Expr::Binary {
8983            lhs: Box::new(Expr::Binary {
8984                lhs: target.clone(),
8985                op: BinOp::GtEq,
8986                rhs: Box::new(low),
8987            }),
8988            op: BinOp::And,
8989            rhs: Box::new(Expr::Binary {
8990                lhs: target,
8991                op: BinOp::LtEq,
8992                rhs: Box::new(high),
8993            }),
8994        };
8995        Ok(maybe_not(combined, negated))
8996    }
8997
8998    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
8999    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
9000    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
9001    /// Caller already consumed the leading `WITH` ident.
9002    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
9003        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
9004        // Comes through as an identifier; consume it if present and
9005        // mark every CTE in the clause as recursive (PG semantics —
9006        // the flag is per-WITH, not per-CTE).
9007        let mut recursive = false;
9008        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9009            && s.eq_ignore_ascii_case("recursive")
9010        {
9011            self.advance();
9012            recursive = true;
9013        }
9014        let mut ctes = Vec::new();
9015        loop {
9016            let name = self.expect_ident_like()?;
9017            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
9018            // PG uses these to rename the body's output columns; we
9019            // do the same below by overriding `columns[i].name`.
9020            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
9021                self.advance();
9022                let mut names = Vec::new();
9023                loop {
9024                    names.push(self.expect_ident_like()?);
9025                    if matches!(self.peek(), Token::Comma) {
9026                        self.advance();
9027                        continue;
9028                    }
9029                    break;
9030                }
9031                if !matches!(self.peek(), Token::RParen) {
9032                    return Err(self.err(format!(
9033                        "expected ')' to close CTE column list, got {:?}",
9034                        self.peek()
9035                    )));
9036                }
9037                self.advance();
9038                names
9039            } else {
9040                Vec::new()
9041            };
9042            // AS is a reserved Token::As (used by SELECT-item / FROM
9043            // aliasing) — handle it specially rather than as a bare
9044            // ident.
9045            if !matches!(self.peek(), Token::As) {
9046                return Err(self.err(format!(
9047                    "expected AS after CTE name {name:?}, got {:?}",
9048                    self.peek()
9049                )));
9050            }
9051            self.advance();
9052            if !matches!(self.peek(), Token::LParen) {
9053                return Err(self.err(format!(
9054                    "expected '(' after AS in WITH clause, got {:?}",
9055                    self.peek()
9056                )));
9057            }
9058            self.advance();
9059            // v7.37.43-T4.4 — accept INSERT / UPDATE / DELETE (with
9060            // RETURNING) as the CTE body in addition to SELECT.
9061            // PG writable CTE semantics. UPDATE / DELETE come in as
9062            // bare Idents (lexer keeps SELECT / INSERT as reserved
9063            // tokens but treats the rest of DML as case-insensitive
9064            // idents).
9065            let is_update_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
9066            let is_delete_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
9067            let body = match self.peek() {
9068                Token::Select => {
9069                    let inner = self.parse_select_stmt()?;
9070                    let Statement::Select(s) = inner else {
9071                        unreachable!("parse_select_stmt returns Select");
9072                    };
9073                    crate::ast::CteBody::Select(s)
9074                }
9075                Token::Insert => {
9076                    let inner = self.parse_one_statement()?;
9077                    let Statement::Insert(s) = inner else {
9078                        unreachable!("Token::Insert routes to Insert");
9079                    };
9080                    crate::ast::CteBody::Insert(alloc::boxed::Box::new(s))
9081                }
9082                _ if is_update_kw => {
9083                    let inner = self.parse_one_statement()?;
9084                    let Statement::Update(s) = inner else {
9085                        return Err(
9086                            self.err(format!("expected UPDATE inside WITH (…), got {inner:?}"))
9087                        );
9088                    };
9089                    crate::ast::CteBody::Update(alloc::boxed::Box::new(s))
9090                }
9091                _ if is_delete_kw => {
9092                    let inner = self.parse_one_statement()?;
9093                    let Statement::Delete(s) = inner else {
9094                        return Err(
9095                            self.err(format!("expected DELETE inside WITH (…), got {inner:?}"))
9096                        );
9097                    };
9098                    crate::ast::CteBody::Delete(alloc::boxed::Box::new(s))
9099                }
9100                other => {
9101                    return Err(self.err(format!(
9102                        "WITH body must be SELECT / INSERT / UPDATE / DELETE, got {other:?}"
9103                    )));
9104                }
9105            };
9106            if !matches!(self.peek(), Token::RParen) {
9107                return Err(self.err(format!(
9108                    "expected ')' after CTE body, got {:?}",
9109                    self.peek()
9110                )));
9111            }
9112            self.advance();
9113            ctes.push(crate::ast::Cte {
9114                name,
9115                body,
9116                recursive,
9117                column_overrides,
9118            });
9119            if matches!(self.peek(), Token::Comma) {
9120                self.advance();
9121                continue;
9122            }
9123            break;
9124        }
9125        // v7.37.43-T4.4 — the outer body may be SELECT (classical),
9126        // or INSERT / UPDATE / DELETE (writable CTE outer). Attach
9127        // the parsed CTEs to whichever statement the body produces.
9128        let outer_is_update = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
9129        let outer_is_delete = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
9130        match self.peek() {
9131            Token::Select => {
9132                let body_stmt = self.parse_select_stmt()?;
9133                let Statement::Select(mut body) = body_stmt else {
9134                    unreachable!()
9135                };
9136                body.ctes = ctes;
9137                Ok(Statement::Select(body))
9138            }
9139            Token::Insert => {
9140                let body_stmt = self.parse_one_statement()?;
9141                let Statement::Insert(mut body) = body_stmt else {
9142                    unreachable!()
9143                };
9144                body.ctes = ctes;
9145                Ok(Statement::Insert(body))
9146            }
9147            _ if outer_is_update => {
9148                let body_stmt = self.parse_one_statement()?;
9149                let Statement::Update(mut body) = body_stmt else {
9150                    return Err(self.err(format!("expected UPDATE after WITH clause")));
9151                };
9152                body.ctes = ctes;
9153                Ok(Statement::Update(body))
9154            }
9155            _ if outer_is_delete => {
9156                let body_stmt = self.parse_one_statement()?;
9157                let Statement::Delete(mut body) = body_stmt else {
9158                    return Err(self.err(format!("expected DELETE after WITH clause")));
9159                };
9160                body.ctes = ctes;
9161                Ok(Statement::Delete(body))
9162            }
9163            other => Err(self.err(format!(
9164                "expected SELECT / INSERT / UPDATE / DELETE after WITH clause, got {other:?}"
9165            ))),
9166        }
9167    }
9168
9169    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
9170    /// already consumed the leading `EXISTS` ident via
9171    /// `self.advance()`.
9172    /// v7.13.0 — parse the rest of a `CASE … END` expression after
9173    /// the leading `CASE` ident has been consumed (mailrs round-5
9174    /// G9). Supports both the searched form
9175    /// (`CASE WHEN cond THEN val …`) and the simple form
9176    /// (`CASE operand WHEN val THEN val …`).
9177    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
9178        // Disambiguate searched vs simple form: if the next token
9179        // is `WHEN`, we're in the searched form. Otherwise the
9180        // intervening expression is the operand.
9181        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
9182            None
9183        } else {
9184            Some(Box::new(self.parse_expr(0)?))
9185        };
9186        let mut branches: Vec<(Expr, Expr)> = Vec::new();
9187        loop {
9188            match self.peek() {
9189                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
9190                    self.advance();
9191                    let cond = self.parse_expr(0)?;
9192                    match self.peek() {
9193                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
9194                            self.advance();
9195                        }
9196                        other => {
9197                            return Err(self.err(alloc::format!(
9198                                "expected THEN after CASE WHEN <expr>, got {other:?}"
9199                            )));
9200                        }
9201                    }
9202                    let value = self.parse_expr(0)?;
9203                    branches.push((cond, value));
9204                }
9205                _ => break,
9206            }
9207        }
9208        if branches.is_empty() {
9209            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
9210        }
9211        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
9212        {
9213            self.advance();
9214            Some(Box::new(self.parse_expr(0)?))
9215        } else {
9216            None
9217        };
9218        match self.peek() {
9219            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
9220                self.advance();
9221            }
9222            other => {
9223                return Err(self.err(alloc::format!(
9224                    "expected END to close CASE expression, got {other:?}"
9225                )));
9226            }
9227        }
9228        Ok(Expr::Case {
9229            operand,
9230            branches,
9231            else_branch,
9232        })
9233    }
9234
9235    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
9236        if !matches!(self.peek(), Token::LParen) {
9237            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
9238        }
9239        self.advance();
9240        let inner = self.parse_select_stmt()?;
9241        if !matches!(self.peek(), Token::RParen) {
9242            return Err(self.err(format!(
9243                "expected ')' after EXISTS-subquery, got {:?}",
9244                self.peek()
9245            )));
9246        }
9247        self.advance();
9248        let Statement::Select(s) = inner else {
9249            unreachable!("parse_select_stmt returns Select")
9250        };
9251        Ok(Expr::Exists {
9252            subquery: Box::new(s),
9253            negated,
9254        })
9255    }
9256
9257    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
9258        self.advance(); // IN
9259        if !matches!(self.peek(), Token::LParen) {
9260            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
9261        }
9262        self.advance();
9263        // v4.10: `IN (SELECT ...)` — subquery branch.
9264        if matches!(self.peek(), Token::Select) {
9265            let inner = self.parse_select_stmt()?;
9266            if !matches!(self.peek(), Token::RParen) {
9267                return Err(self.err(format!(
9268                    "expected ')' after IN-subquery, got {:?}",
9269                    self.peek()
9270                )));
9271            }
9272            self.advance();
9273            let Statement::Select(s) = inner else {
9274                unreachable!("parse_select_stmt always returns Statement::Select")
9275            };
9276            return Ok(Expr::InSubquery {
9277                expr: Box::new(expr),
9278                subquery: Box::new(s),
9279                negated,
9280            });
9281        }
9282        let mut elements = Vec::new();
9283        if !matches!(self.peek(), Token::RParen) {
9284            loop {
9285                elements.push(self.parse_expr(0)?);
9286                match self.peek() {
9287                    Token::Comma => {
9288                        self.advance();
9289                    }
9290                    Token::RParen => break,
9291                    other => {
9292                        return Err(
9293                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
9294                        );
9295                    }
9296                }
9297            }
9298        }
9299        self.advance(); // ')'
9300        // v7.30.2 (mailrs round-25) — flat InList node instead of a
9301        // left-deep OR-Eq chain: chain depth scaled with the element
9302        // count and overflowed the stack (eval + drop are recursive).
9303        if elements.is_empty() {
9304            return Ok(maybe_not(Expr::Literal(Literal::Bool(false)), negated));
9305        }
9306        Ok(Expr::InList {
9307            expr: Box::new(expr),
9308            list: elements,
9309            negated,
9310        })
9311    }
9312
9313    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
9314    /// already consumed by the caller. Elements must be numeric literals
9315    /// (with optional unary `-`); any compound expression is rejected at
9316    /// parse time so the runtime never needs to evaluate inside a vector.
9317    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
9318    /// has already consumed the `EXTRACT` token before calling us —
9319    /// we pick up at the opening `(`.
9320    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
9321    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
9322    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
9323    /// per-column OR-fold of
9324    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
9325    /// term)` so the existing FTS evaluator handles semantics.
9326    ///
9327    /// The mode modifier is accepted-and-ignored at v7.17 — all
9328    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
9329    /// mode operators (`+foo -bar`) would need their own parser
9330    /// (Phase 2.2c); customers who hit them today already get a
9331    /// correct lexeme-match against the bare term, only without
9332    /// the +/- precedence the customer asked for.
9333    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
9334        // Already at `MATCH`-consumed position; the dispatcher
9335        // confirmed the next token is `(`.
9336        if !matches!(self.peek(), Token::LParen) {
9337            return Err(self.err(alloc::format!(
9338                "expected '(' after MATCH, got {:?}",
9339                self.peek()
9340            )));
9341        }
9342        self.advance();
9343        let mut cols: Vec<Expr> = Vec::new();
9344        loop {
9345            cols.push(self.parse_expr(0)?);
9346            match self.peek() {
9347                Token::Comma => {
9348                    self.advance();
9349                }
9350                Token::RParen => break,
9351                other => {
9352                    return Err(self.err(alloc::format!(
9353                        "expected ',' or ')' in MATCH column list, got {other:?}"
9354                    )));
9355                }
9356            }
9357        }
9358        self.advance(); // ')'
9359        // Expect AGAINST.
9360        match self.peek() {
9361            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
9362                self.advance();
9363            }
9364            other => {
9365                return Err(self.err(alloc::format!(
9366                    "expected AGAINST after MATCH column list, got {other:?}"
9367                )));
9368            }
9369        }
9370        if !matches!(self.peek(), Token::LParen) {
9371            return Err(self.err(alloc::format!(
9372                "expected '(' after AGAINST, got {:?}",
9373                self.peek()
9374            )));
9375        }
9376        self.advance();
9377        // Read AGAINST's argument as a single primary token —
9378        // string literal, placeholder, or column-ref ident. We
9379        // can't call `parse_expr` / `parse_unary` here because
9380        // the postfix chain inside `parse_atom` would greedily
9381        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
9382        // and fail at "expected '(' after IN". Customers always
9383        // write a literal or bound parameter in AGAINST, so this
9384        // restriction is non-blocking; the error path explains
9385        // the limit if a more complex expression shows up.
9386        let term = match self.advance() {
9387            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
9388            Token::Placeholder(n) => Expr::Placeholder(n),
9389            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
9390                qualifier: None,
9391                name: s,
9392            }),
9393            other => {
9394                return Err(self.err(alloc::format!(
9395                    "MATCH ... AGAINST(<term>) expects a string literal, \
9396                     bound parameter, or column ref, got {other:?}"
9397                )));
9398            }
9399        };
9400        // Optional mode tail — accept-and-ignore at v7.17:
9401        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
9402        //   IN BOOLEAN MODE
9403        //   WITH QUERY EXPANSION
9404        loop {
9405            match self.peek() {
9406                // IN lexes as a reserved Token::In, not an ident,
9407                // so it gets its own arm.
9408                Token::In => {
9409                    self.advance();
9410                }
9411                Token::Ident(s) | Token::QuotedIdent(s)
9412                    if s.eq_ignore_ascii_case("natural")
9413                        || s.eq_ignore_ascii_case("language")
9414                        || s.eq_ignore_ascii_case("boolean")
9415                        || s.eq_ignore_ascii_case("mode")
9416                        || s.eq_ignore_ascii_case("with")
9417                        || s.eq_ignore_ascii_case("query")
9418                        || s.eq_ignore_ascii_case("expansion") =>
9419                {
9420                    self.advance();
9421                }
9422                _ => break,
9423            }
9424        }
9425        if !matches!(self.peek(), Token::RParen) {
9426            return Err(self.err(alloc::format!(
9427                "expected ')' to close AGAINST, got {:?}",
9428                self.peek()
9429            )));
9430        }
9431        self.advance();
9432        // Build per-column `to_tsvector('simple', col) @@
9433        // plainto_tsquery('simple', term)` and OR-fold.
9434        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
9435        let plainto = Expr::FunctionCall {
9436            name: String::from("plainto_tsquery"),
9437            args: alloc::vec![simple_lit(), term.clone()],
9438        };
9439        let mut folded: Option<Expr> = None;
9440        for col in cols {
9441            let to_tsv = Expr::FunctionCall {
9442                name: String::from("to_tsvector"),
9443                args: alloc::vec![simple_lit(), col],
9444            };
9445            let leaf = Expr::Binary {
9446                lhs: Box::new(to_tsv),
9447                op: crate::ast::BinOp::TsMatch,
9448                rhs: Box::new(plainto.clone()),
9449            };
9450            folded = Some(match folded {
9451                None => leaf,
9452                Some(prev) => Expr::Binary {
9453                    lhs: Box::new(prev),
9454                    op: crate::ast::BinOp::Or,
9455                    rhs: Box::new(leaf),
9456                },
9457            });
9458        }
9459        match folded {
9460            Some(e) => Ok(e),
9461            None => Err(self.err(String::from(
9462                "MATCH(...) AGAINST(...) requires at least one column",
9463            ))),
9464        }
9465    }
9466
9467    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
9468        if !matches!(self.peek(), Token::LParen) {
9469            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
9470        }
9471        self.advance();
9472        let field_name = self.expect_ident_like()?;
9473        let field = match field_name.to_ascii_lowercase().as_str() {
9474            "year" => ExtractField::Year,
9475            "month" => ExtractField::Month,
9476            "day" => ExtractField::Day,
9477            "hour" => ExtractField::Hour,
9478            "minute" => ExtractField::Minute,
9479            "second" => ExtractField::Second,
9480            "microsecond" | "microseconds" => ExtractField::Microsecond,
9481            "epoch" => ExtractField::Epoch,
9482            other => {
9483                return Err(self.err(format!(
9484                    "unknown EXTRACT field {other:?}; \
9485                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
9486                )));
9487            }
9488        };
9489        if !matches!(self.peek(), Token::From) {
9490            return Err(self.err(format!(
9491                "expected FROM after EXTRACT field, got {:?}",
9492                self.peek()
9493            )));
9494        }
9495        self.advance();
9496        let source = self.parse_expr(0)?;
9497        if !matches!(self.peek(), Token::RParen) {
9498            return Err(self.err(format!(
9499                "expected ')' to close EXTRACT, got {:?}",
9500                self.peek()
9501            )));
9502        }
9503        self.advance();
9504        Ok(Expr::Extract {
9505            field,
9506            source: Box::new(source),
9507        })
9508    }
9509
9510    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
9511    /// is already consumed; we expect a single string literal next and
9512    /// resolve it into `Literal::Interval` at parse time so the engine
9513    /// never has to re-tokenise inside the string.
9514    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
9515        let tok = self.advance();
9516        let Token::String(text) = tok else {
9517            return Err(self.err(format!(
9518                "expected string literal after INTERVAL, got {tok:?}"
9519            )));
9520        };
9521        let (months, days, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
9522            message: format!(
9523                "cannot parse INTERVAL {text:?}; \
9524                     expected `<n> <unit> [<n> <unit> ...]` with units \
9525                     microsecond[s], millisecond[s], second[s], minute[s], \
9526                     hour[s], day[s], week[s], month[s], year[s]"
9527            ),
9528            token_pos: self.pos.saturating_sub(1),
9529        })?;
9530        Ok(Expr::Literal(Literal::Interval {
9531            months,
9532            days,
9533            micros,
9534            text,
9535        }))
9536    }
9537
9538    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
9539        let mut elems = Vec::new();
9540        if matches!(self.peek(), Token::RBracket) {
9541            self.advance();
9542            return Ok(Expr::Literal(Literal::Vector(elems)));
9543        }
9544        loop {
9545            let e = self.parse_expr(0)?;
9546            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
9547                message: format!("vector element must be a numeric literal, got {e:?}"),
9548                token_pos: self.pos,
9549            })?;
9550            elems.push(x);
9551            match self.peek() {
9552                Token::Comma => {
9553                    self.advance();
9554                }
9555                Token::RBracket => {
9556                    self.advance();
9557                    break;
9558                }
9559                other => {
9560                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
9561                }
9562            }
9563        }
9564        Ok(Expr::Literal(Literal::Vector(elems)))
9565    }
9566
9567    /// Atom that started with an identifier: could be `t.col`, `col`, or
9568    /// `func(arg, ...)`. Detect each shape by looking at the next token.
9569    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
9570    /// [, ...])`. Caller has already consumed `OVER`. Either clause
9571    /// is optional; an empty `()` is also legal (PG semantics).
9572    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
9573    /// modifier between `name(args)` and `OVER (...)`. Default is
9574    /// `Respect`. Unrecognised idents leave the stream unchanged.
9575    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
9576        let Token::Ident(s) = self.peek().clone() else {
9577            return NullTreatment::Respect;
9578        };
9579        let is_ignore = s.eq_ignore_ascii_case("ignore");
9580        let is_respect = s.eq_ignore_ascii_case("respect");
9581        if !is_ignore && !is_respect {
9582            return NullTreatment::Respect;
9583        }
9584        // Lookahead for NULLS — only consume both tokens together.
9585        // pos+1 must hold a "nulls" ident.
9586        if self.pos + 1 < self.tokens.len()
9587            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
9588            && s2.eq_ignore_ascii_case("nulls")
9589        {
9590            self.advance();
9591            self.advance();
9592            return if is_ignore {
9593                NullTreatment::Ignore
9594            } else {
9595                NullTreatment::Respect
9596            };
9597        }
9598        NullTreatment::Respect
9599    }
9600
9601    /// v7.32 (mailrs round-29) — `agg(args) FILTER (WHERE cond)`.
9602    /// `FILTER` is an unreserved keyword, so it arrives as an `Ident`
9603    /// (same shape as the `OVER` tail). Consumes the whole clause and
9604    /// returns the predicate; returns `None` when no `FILTER` follows.
9605    fn parse_filter_clause(&mut self) -> Result<Option<Box<Expr>>, ParseError> {
9606        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9607            return Ok(None);
9608        };
9609        if !s.eq_ignore_ascii_case("filter") {
9610            return Ok(None);
9611        }
9612        self.advance(); // FILTER
9613        if !matches!(self.peek(), Token::LParen) {
9614            return Err(self.err(format!("expected '(' after FILTER, got {:?}", self.peek())));
9615        }
9616        self.advance(); // (
9617        if !matches!(self.peek(), Token::Where) {
9618            return Err(self.err(format!(
9619                "expected WHERE inside FILTER (...), got {:?}",
9620                self.peek()
9621            )));
9622        }
9623        self.advance(); // WHERE
9624        let cond = self.parse_expr(0)?;
9625        if !matches!(self.peek(), Token::RParen) {
9626            return Err(self.err(format!(
9627                "expected ')' to close FILTER (WHERE ...), got {:?}",
9628                self.peek()
9629            )));
9630        }
9631        self.advance(); // )
9632        Ok(Some(Box::new(cond)))
9633    }
9634
9635    /// v7.32 (round-29) — `WITHIN GROUP ( ORDER BY <sort_spec> )` tail
9636    /// for ordered-set aggregates. `WITHIN` is unreserved (arrives as an
9637    /// `Ident`); `GROUP` and `ORDER`/`BY` are keywords. Returns the sort
9638    /// keys, or an empty vec when no `WITHIN GROUP` follows.
9639    fn parse_within_group_clause(&mut self) -> Result<Vec<OrderBy>, ParseError> {
9640        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9641            return Ok(Vec::new());
9642        };
9643        if !s.eq_ignore_ascii_case("within") {
9644            return Ok(Vec::new());
9645        }
9646        self.advance(); // WITHIN
9647        if !matches!(self.peek(), Token::Group) {
9648            return Err(self.err(format!(
9649                "expected GROUP after WITHIN, got {:?}",
9650                self.peek()
9651            )));
9652        }
9653        self.advance(); // GROUP
9654        if !matches!(self.peek(), Token::LParen) {
9655            return Err(self.err(format!(
9656                "expected '(' after WITHIN GROUP, got {:?}",
9657                self.peek()
9658            )));
9659        }
9660        self.advance(); // (
9661        if !matches!(self.peek(), Token::Order) {
9662            return Err(self.err(format!(
9663                "expected ORDER BY inside WITHIN GROUP (...), got {:?}",
9664                self.peek()
9665            )));
9666        }
9667        self.advance(); // ORDER
9668        if !matches!(self.peek(), Token::By) {
9669            return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9670        }
9671        self.advance(); // BY
9672        let mut keys: Vec<OrderBy> = Vec::new();
9673        loop {
9674            let expr = self.parse_expr(0)?;
9675            let desc = if matches!(self.peek(), Token::Desc) {
9676                self.advance();
9677                true
9678            } else if matches!(self.peek(), Token::Asc) {
9679                self.advance();
9680                false
9681            } else {
9682                false
9683            };
9684            let nulls_first = self.parse_optional_nulls_placement()?;
9685            keys.push(OrderBy {
9686                expr,
9687                desc,
9688                nulls_first,
9689            });
9690            if matches!(self.peek(), Token::Comma) {
9691                self.advance();
9692            } else {
9693                break;
9694            }
9695        }
9696        if !matches!(self.peek(), Token::RParen) {
9697            return Err(self.err(format!(
9698                "expected ')' to close WITHIN GROUP (ORDER BY ...), got {:?}",
9699                self.peek()
9700            )));
9701        }
9702        self.advance(); // )
9703        Ok(keys)
9704    }
9705
9706    /// No frame clause is supported.
9707    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
9708    fn parse_over_clause(
9709        &mut self,
9710    ) -> Result<
9711        (
9712            Vec<Expr>,
9713            Vec<(Expr, bool, Option<bool>)>,
9714            Option<WindowFrame>,
9715        ),
9716        ParseError,
9717    > {
9718        if !matches!(self.peek(), Token::LParen) {
9719            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
9720        }
9721        self.advance();
9722        let mut partition_by = Vec::new();
9723        let mut order_by = Vec::new();
9724        // PARTITION BY ?
9725        // v7.37.6-B promoted PARTITION to a reserved keyword
9726        // (Token::Partition); pre-7.37.6-B catalogs lexed it as
9727        // `Token::Ident("partition")`. Accept both so older sources
9728        // and the new lexer surface land on the same path.
9729        let is_partition_kw = match self.peek() {
9730            Token::Partition => true,
9731            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("partition"),
9732            _ => false,
9733        };
9734        if is_partition_kw {
9735            self.advance();
9736            if !matches!(self.peek(), Token::By) {
9737                return Err(self.err(format!(
9738                    "expected BY after PARTITION, got {:?}",
9739                    self.peek()
9740                )));
9741            }
9742            self.advance();
9743            loop {
9744                partition_by.push(self.parse_expr(0)?);
9745                if matches!(self.peek(), Token::Comma) {
9746                    self.advance();
9747                    continue;
9748                }
9749                break;
9750            }
9751        }
9752        // ORDER BY ?
9753        if matches!(self.peek(), Token::Order) {
9754            self.advance();
9755            if !matches!(self.peek(), Token::By) {
9756                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9757            }
9758            self.advance();
9759            loop {
9760                let e = self.parse_expr(0)?;
9761                let desc = if matches!(self.peek(), Token::Desc) {
9762                    self.advance();
9763                    true
9764                } else if matches!(self.peek(), Token::Asc) {
9765                    self.advance();
9766                    false
9767                } else {
9768                    false
9769                };
9770                // v7.24.1 — NULLS FIRST/LAST inside OVER (…).
9771                let nulls_first = self.parse_optional_nulls_placement()?;
9772                order_by.push((e, desc, nulls_first));
9773                if matches!(self.peek(), Token::Comma) {
9774                    self.advance();
9775                    continue;
9776                }
9777                break;
9778            }
9779        }
9780        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
9781        // Both keywords come through the lexer as identifiers; match
9782        // case-insensitively.
9783        let mut frame: Option<WindowFrame> = None;
9784        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
9785            let kind = if s.eq_ignore_ascii_case("rows") {
9786                Some(FrameKind::Rows)
9787            } else if s.eq_ignore_ascii_case("range") {
9788                Some(FrameKind::Range)
9789            } else {
9790                None
9791            };
9792            if let Some(kind) = kind {
9793                self.advance();
9794                frame = Some(self.parse_frame_tail(kind)?);
9795            }
9796        }
9797        if !matches!(self.peek(), Token::RParen) {
9798            return Err(self.err(format!(
9799                "expected ')' to close OVER clause, got {:?}",
9800                self.peek()
9801            )));
9802        }
9803        self.advance();
9804        Ok((partition_by, order_by, frame))
9805    }
9806
9807    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
9808    /// or `RANGE` keyword was just consumed. Accepts both
9809    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
9810    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
9811    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
9812    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
9813        if matches!(self.peek(), Token::Between) {
9814            self.advance();
9815            let start = self.parse_frame_bound()?;
9816            if !matches!(self.peek(), Token::And) {
9817                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
9818            }
9819            self.advance();
9820            let end = self.parse_frame_bound()?;
9821            Ok(WindowFrame {
9822                kind,
9823                start,
9824                end: Some(end),
9825            })
9826        } else {
9827            let start = self.parse_frame_bound()?;
9828            Ok(WindowFrame {
9829                kind,
9830                start,
9831                end: None,
9832            })
9833        }
9834    }
9835
9836    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
9837    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
9838    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
9839        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
9840        if let Token::Integer(n) = *self.peek() {
9841            self.advance();
9842            let n: u64 = u64::try_from(n).map_err(|_| {
9843                self.err(format!(
9844                    "invalid frame offset {n} — expected non-negative integer"
9845                ))
9846            })?;
9847            let dir = self.expect_ident_like()?;
9848            return if dir.eq_ignore_ascii_case("preceding") {
9849                Ok(FrameBound::OffsetPreceding(n))
9850            } else if dir.eq_ignore_ascii_case("following") {
9851                Ok(FrameBound::OffsetFollowing(n))
9852            } else {
9853                Err(self.err(format!(
9854                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
9855                )))
9856            };
9857        }
9858        let first = self.expect_ident_like()?;
9859        if first.eq_ignore_ascii_case("unbounded") {
9860            let dir = self.expect_ident_like()?;
9861            return if dir.eq_ignore_ascii_case("preceding") {
9862                Ok(FrameBound::UnboundedPreceding)
9863            } else if dir.eq_ignore_ascii_case("following") {
9864                Ok(FrameBound::UnboundedFollowing)
9865            } else {
9866                Err(self.err(format!(
9867                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
9868                )))
9869            };
9870        }
9871        if first.eq_ignore_ascii_case("current") {
9872            let row = self.expect_ident_like()?;
9873            if !row.eq_ignore_ascii_case("row") {
9874                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
9875            }
9876            return Ok(FrameBound::CurrentRow);
9877        }
9878        Err(self.err(format!(
9879            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
9880        )))
9881    }
9882
9883    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
9884        if matches!(self.peek(), Token::Dot) {
9885            self.advance();
9886            let name = self.expect_ident_like()?;
9887            // v7.14.0 — schema-qualified function call
9888            // `<schema>.<fn>(args)`. PG dumps emit
9889            // `pg_catalog.set_config(...)` in the preamble. SPG
9890            // is single-namespace: drop the schema prefix and
9891            // route the dispatch on the bare function name.
9892            if matches!(self.peek(), Token::LParen) {
9893                return self.finish_ident_atom(name);
9894            }
9895            return Ok(Expr::Column(ColumnName {
9896                qualifier: Some(first),
9897                name,
9898            }));
9899        }
9900        if matches!(self.peek(), Token::LParen) {
9901            self.advance();
9902            // `COUNT(*)` — special-cased here because `*` isn't a normal
9903            // expression token. Lower-case match on `first` since the lexer
9904            // folds identifiers.
9905            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
9906                self.advance();
9907                if !matches!(self.peek(), Token::RParen) {
9908                    return Err(self.err(format!(
9909                        "expected ')' after COUNT(*), got {:?}",
9910                        self.peek()
9911                    )));
9912                }
9913                self.advance();
9914                // v7.32 (round-29) — `COUNT(*) FILTER (WHERE …)`.
9915                let filter = self.parse_filter_clause()?;
9916                // v4.12: COUNT(*) OVER (...) — same window tail.
9917                let null_treatment = self.parse_null_treatment_modifier();
9918                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9919                    && s.eq_ignore_ascii_case("over")
9920                {
9921                    if filter.is_some() {
9922                        return Err(
9923                            self.err("FILTER on window functions is not supported yet".into())
9924                        );
9925                    }
9926                    self.advance();
9927                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
9928                    return Ok(Expr::WindowFunction {
9929                        name: "count_star".into(),
9930                        args: Vec::new(),
9931                        partition_by,
9932                        order_by,
9933                        frame,
9934                        null_treatment,
9935                    });
9936                }
9937                if let Some(filter) = filter {
9938                    return Ok(Expr::AggregateOrdered {
9939                        call: Box::new(Expr::FunctionCall {
9940                            name: "count_star".into(),
9941                            args: Vec::new(),
9942                        }),
9943                        order_by: Vec::new(),
9944                        distinct: false,
9945                        filter: Some(filter),
9946                    });
9947                }
9948                return Ok(Expr::FunctionCall {
9949                    name: "count_star".into(),
9950                    args: Vec::new(),
9951                });
9952            }
9953            // Function call. PG-style: zero-or-more comma-separated args.
9954            let mut args = Vec::new();
9955            let mut agg_order_by: Vec<OrderBy> = Vec::new();
9956            // v7.25 (round-17) — `COUNT(DISTINCT x)` and friends.
9957            // v7.32 (round-29) — accept the dual `ALL` quantifier too
9958            // (the default; ORMs emit `COUNT(ALL x)` / `SUM(ALL x)`).
9959            let agg_distinct = if matches!(self.peek(), Token::Distinct) {
9960                self.advance();
9961                true
9962            } else if matches!(self.peek(), Token::All) {
9963                self.advance();
9964                false
9965            } else {
9966                false
9967            };
9968            if !matches!(self.peek(), Token::RParen) {
9969                loop {
9970                    args.push(self.parse_expr(0)?);
9971                    // v7.25 (round-17) — standard `CAST(expr AS type)`.
9972                    // The `::` cast already worked; this lowers the
9973                    // function form onto the same Expr::Cast node.
9974                    if first.eq_ignore_ascii_case("cast")
9975                        && args.len() == 1
9976                        && matches!(self.peek(), Token::As)
9977                    {
9978                        self.advance();
9979                        let target = self.parse_cast_target()?;
9980                        if !matches!(self.peek(), Token::RParen) {
9981                            return Err(self.err(format!(
9982                                "expected ')' to close CAST, got {:?}",
9983                                self.peek()
9984                            )));
9985                        }
9986                        self.advance();
9987                        return Ok(Expr::Cast {
9988                            expr: Box::new(args.pop().expect("one arg")),
9989                            target,
9990                        });
9991                    }
9992                    // v7.37.7 C.1.8 — PG `substring(str FROM pos FOR len)` syntactic
9993                    // form. Desugars to the comma-list shape evaluator already
9994                    // handles. Triggered after the first arg when the function
9995                    // name is substring / substr and the next token is FROM
9996                    // (a reserved keyword in PG; SPG also reserves it).
9997                    if (first.eq_ignore_ascii_case("substring")
9998                        || first.eq_ignore_ascii_case("substr"))
9999                        && args.len() == 1
10000                        && matches!(self.peek(), Token::From)
10001                    {
10002                        self.advance();
10003                        let start = self.parse_expr(0)?;
10004                        args.push(start);
10005                        if matches!(self.peek(), Token::For) {
10006                            self.advance();
10007                            let length = self.parse_expr(0)?;
10008                            args.push(length);
10009                        }
10010                        if !matches!(self.peek(), Token::RParen) {
10011                            return Err(self.err(format!(
10012                                "expected ')' to close substring(... FROM ... [FOR ...]), got {:?}",
10013                                self.peek()
10014                            )));
10015                        }
10016                        self.advance();
10017                        return Ok(Expr::FunctionCall {
10018                            name: first.to_ascii_lowercase(),
10019                            args,
10020                        });
10021                    }
10022                    // v7.24 (round-16 A) — aggregate-internal
10023                    // ordering: `array_agg(x ORDER BY y DESC NULLS
10024                    // LAST)`. Keys close the argument list.
10025                    if matches!(self.peek(), Token::Order) {
10026                        self.advance();
10027                        if !matches!(self.peek(), Token::By) {
10028                            return Err(self.err(format!(
10029                                "expected BY after ORDER in aggregate args, got {:?}",
10030                                self.peek()
10031                            )));
10032                        }
10033                        self.advance();
10034                        loop {
10035                            let expr = self.parse_expr(0)?;
10036                            let desc = if matches!(self.peek(), Token::Desc) {
10037                                self.advance();
10038                                true
10039                            } else if matches!(self.peek(), Token::Asc) {
10040                                self.advance();
10041                                false
10042                            } else {
10043                                false
10044                            };
10045                            let nulls_first = self.parse_optional_nulls_placement()?;
10046                            agg_order_by.push(OrderBy {
10047                                expr,
10048                                desc,
10049                                nulls_first,
10050                            });
10051                            if matches!(self.peek(), Token::Comma) {
10052                                self.advance();
10053                            } else {
10054                                break;
10055                            }
10056                        }
10057                        if !matches!(self.peek(), Token::RParen) {
10058                            return Err(self.err(format!(
10059                                "expected ')' after aggregate ORDER BY, got {:?}",
10060                                self.peek()
10061                            )));
10062                        }
10063                        break;
10064                    }
10065                    match self.peek() {
10066                        Token::Comma => {
10067                            self.advance();
10068                        }
10069                        Token::RParen => break,
10070                        other => {
10071                            return Err(self.err(format!(
10072                                "expected ',' or ')' in function args, got {other:?}"
10073                            )));
10074                        }
10075                    }
10076                }
10077            }
10078            self.advance(); // consume ')'
10079            // v7.32 (round-29) — ordered-set aggregate tail
10080            // `name(direct_args) WITHIN GROUP (ORDER BY …)`
10081            // (percentile_cont / percentile_disc / mode). The sort spec
10082            // lands in the same `order_by` slot a decorated aggregate
10083            // uses; the executor dispatches on the function name. WITHIN
10084            // GROUP and an intra-argument ORDER BY are mutually
10085            // exclusive (PG rejects both).
10086            let within_group_order = self.parse_within_group_clause()?;
10087            if !within_group_order.is_empty() && !agg_order_by.is_empty() {
10088                return Err(self.err(
10089                    "an aggregate may not carry both an in-argument ORDER BY and WITHIN GROUP"
10090                        .into(),
10091                ));
10092            }
10093            let agg_order_by = if within_group_order.is_empty() {
10094                agg_order_by
10095            } else {
10096                within_group_order
10097            };
10098            // v7.32 (round-29) — `name(args) FILTER (WHERE …)`.
10099            let filter = self.parse_filter_clause()?;
10100            // v4.12: window-function tail — `name(args) OVER (...)`.
10101            // Promotes the just-parsed FunctionCall into a
10102            // WindowFunction node carrying partition + order.
10103            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
10104            // / `RESPECT NULLS OVER (...)` between the closing paren
10105            // and `OVER`.
10106            let null_treatment = self.parse_null_treatment_modifier();
10107            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
10108                && s.eq_ignore_ascii_case("over")
10109            {
10110                if filter.is_some() {
10111                    return Err(self.err("FILTER on window functions is not supported yet".into()));
10112                }
10113                self.advance();
10114                let (partition_by, order_by, frame) = self.parse_over_clause()?;
10115                return Ok(Expr::WindowFunction {
10116                    name: first,
10117                    args,
10118                    partition_by,
10119                    order_by,
10120                    frame,
10121                    null_treatment,
10122                });
10123            }
10124            if !agg_order_by.is_empty() || agg_distinct || filter.is_some() {
10125                return Ok(Expr::AggregateOrdered {
10126                    call: Box::new(Expr::FunctionCall { name: first, args }),
10127                    order_by: agg_order_by,
10128                    distinct: agg_distinct,
10129                    filter,
10130                });
10131            }
10132            return Ok(Expr::FunctionCall { name: first, args });
10133        }
10134        // v7.9.20 — SQL-standard parenless keyword expressions
10135        // (PG treats these as functions called without parens).
10136        // Resolve to a synthetic FunctionCall so the engine's
10137        // eval path reuses the existing function-call routing.
10138        // mailrs G3.
10139        let lc = first.to_ascii_lowercase();
10140        if matches!(
10141            lc.as_str(),
10142            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
10143        ) {
10144            return Ok(Expr::FunctionCall {
10145                name: lc,
10146                args: Vec::new(),
10147            });
10148        }
10149        Ok(Expr::Column(ColumnName {
10150            qualifier: None,
10151            name: first,
10152        }))
10153    }
10154}
10155
10156/// v6.8.2 — walk an expression tree and return the first column
10157/// reference's bare name. Used by `parse_create_index_stmt_after_create`
10158/// to derive `CreateIndexStatement.column` from an expression
10159/// key (so downstream planner code resolving a primary column
10160/// position keeps working with expression indexes). Returns
10161/// `None` when the expression has no column ref at all — caller
10162/// surfaces that as a parse error.
10163fn extract_first_column(expr: &Expr) -> Option<String> {
10164    match expr {
10165        Expr::Column(cn) => Some(cn.name.clone()),
10166        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
10167        Expr::Binary { lhs, rhs, .. } => {
10168            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
10169        }
10170        Expr::Unary { expr: e, .. } => extract_first_column(e),
10171        _ => None,
10172    }
10173}
10174
10175fn maybe_not(expr: Expr, negated: bool) -> Expr {
10176    if negated {
10177        Expr::Unary {
10178            op: UnOp::Not,
10179            expr: Box::new(expr),
10180        }
10181    } else {
10182        expr
10183    }
10184}
10185
10186fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
10187    let pair = match tok {
10188        Token::Or => (BinOp::Or, 1),
10189        Token::And => (BinOp::And, 2),
10190        Token::Eq => (BinOp::Eq, 4),
10191        Token::NotEq => (BinOp::NotEq, 4),
10192        Token::Lt => (BinOp::Lt, 4),
10193        Token::LtEq => (BinOp::LtEq, 4),
10194        Token::Gt => (BinOp::Gt, 4),
10195        Token::GtEq => (BinOp::GtEq, 4),
10196        // pgvector distance ops all sit on the same rung — tighter than
10197        // comparisons (4) so `col <-> v < threshold` parses correctly.
10198        Token::L2Distance => (BinOp::L2Distance, 5),
10199        Token::InnerProduct => (BinOp::InnerProduct, 5),
10200        Token::CosineDistance => (BinOp::CosineDistance, 5),
10201        Token::Plus => (BinOp::Add, 6),
10202        Token::Minus => (BinOp::Sub, 6),
10203        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
10204        // by the same level as binary additive arithmetic).
10205        Token::Concat => (BinOp::Concat, 6),
10206        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
10207        // all "other" operators between additive and comparison, so
10208        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
10209        //
10210        // Known divergence (the same one `||` has carried since v1):
10211        // SPG's rung 6 TIES with `+ -`, while PG binds generic
10212        // operators LOOSER than additive — `a & b + 1` is
10213        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
10214        // mixed bitwise/arithmetic. Keeping every generic operator
10215        // on one shared rung is deliberate: splitting bitwise off
10216        // would fix that case but skew `a || b & c`, which PG
10217        // left-folds at a single level.
10218        Token::Pipe => (BinOp::BitOr, 6),
10219        Token::Amp => (BinOp::BitAnd, 6),
10220        Token::Star => (BinOp::Mul, 7),
10221        Token::Slash => (BinOp::Div, 7),
10222        Token::Percent => (BinOp::Mod, 7),
10223        // v4.14: JSON path ops bind tighter than comparisons (4)
10224        // and additive (6) so `doc->'k' = 'v'` parses correctly.
10225        // Same rung as the multiplicative ops.
10226        Token::JsonGet => (BinOp::JsonGet, 7),
10227        Token::JsonGetText => (BinOp::JsonGetText, 7),
10228        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
10229        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
10230        Token::JsonContains => (BinOp::JsonContains, 7),
10231        Token::JsonContainedBy => (BinOp::JsonContainedBy, 7),
10232        Token::JsonKeyExists => (BinOp::JsonKeyExists, 7),
10233        Token::JsonKeysAny => (BinOp::JsonKeysAny, 7),
10234        Token::JsonKeysAll => (BinOp::JsonKeysAll, 7),
10235        // v7.12.2 — `@@` binds at the comparison rung (looser than
10236        // arithmetic, tighter than AND / OR). PG places `@@` at
10237        // the same precedence as `=` / `<`, so we follow.
10238        Token::TsMatch => (BinOp::TsMatch, 4),
10239        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
10240        // PG places these at the comparison rung (same level as `=`),
10241        // so we follow.
10242        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
10243        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
10244        Token::InetContains => (BinOp::InetContains, 4),
10245        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
10246        Token::InetOverlap => (BinOp::InetOverlap, 4),
10247        _ => return None,
10248    };
10249    Some(pair)
10250}
10251
10252#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
10253// `as f32` here is intentional: vector elements widen / narrow into f32 on
10254// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
10255// past ~15 decimal digits — both are acceptable for a fixed-precision
10256// pgvector column.
10257/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
10258/// implicit table alias and break trailing clauses. WITH lands
10259/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
10260/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
10261/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
10262/// / VALUES / FOR / LATERAL — all of which would otherwise be
10263/// silently swallowed by `parse_optional_alias`.
10264fn is_alias_stopword(s: &str) -> bool {
10265    matches!(
10266        s.to_ascii_lowercase().as_str(),
10267        "with"
10268            | "on"
10269            | "where"
10270            | "having"
10271            | "group"
10272            | "order"
10273            | "limit"
10274            | "offset"
10275            | "union"
10276            | "except"
10277            | "intersect"
10278            | "returning"
10279            | "set"
10280            | "values"
10281            | "for"
10282            | "lateral"
10283            | "left"
10284            | "right"
10285            | "inner"
10286            | "outer"
10287            | "full"
10288            | "cross"
10289            | "join"
10290            | "natural"
10291            | "using"
10292            | "fetch"
10293    )
10294}
10295
10296fn extract_numeric_literal(e: &Expr) -> Option<f32> {
10297    match e {
10298        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
10299        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
10300        Expr::Unary {
10301            op: UnOp::Neg,
10302            expr,
10303        } => extract_numeric_literal(expr).map(|x| -x),
10304        _ => None,
10305    }
10306}
10307
10308/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
10309/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
10310/// negative. Returns `None` if any pair fails to parse or no pair is found.
10311///
10312/// Recognised units (case-insensitive, optional trailing `s`):
10313/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
10314/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
10315/// v7.37.5 β — returns `(months, days, micros)`. `days` is preserved
10316/// as its own dimension so `INTERVAL '1 day'` ≠ `INTERVAL '24 hours'`
10317/// (PG-canonical: DST and month-boundary semantics depend on this).
10318/// `week` rolls into `days` (× 7). Sub-day units flow into `micros`.
10319pub fn parse_interval_text(s: &str) -> Option<(i32, i32, i64)> {
10320    let parts: Vec<&str> = s.split_whitespace().collect();
10321    if parts.is_empty() || !parts.len().is_multiple_of(2) {
10322        return None;
10323    }
10324    let mut months: i32 = 0;
10325    let mut days: i32 = 0;
10326    let mut micros: i64 = 0;
10327    let mut i = 0;
10328    while i < parts.len() {
10329        let n: i64 = parts[i].parse().ok()?;
10330        let unit = parts[i + 1].to_ascii_lowercase();
10331        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
10332        match unit_stripped {
10333            "microsecond" => micros = micros.checked_add(n)?,
10334            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
10335            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
10336            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
10337            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
10338            "day" => {
10339                let n32 = i32::try_from(n).ok()?;
10340                days = days.checked_add(n32)?;
10341            }
10342            "week" => {
10343                let n32 = i32::try_from(n).ok()?;
10344                days = days.checked_add(n32.checked_mul(7)?)?;
10345            }
10346            // v7.37.5 ship triage — accept PG's `format_interval`
10347            // canonical output (`0 mons 0 days 0 microseconds`) so
10348            // a round-trip Display → re-parse stays lossless.
10349            "month" | "mon" => {
10350                let n32 = i32::try_from(n).ok()?;
10351                months = months.checked_add(n32)?;
10352            }
10353            "year" => {
10354                let n32 = i32::try_from(n).ok()?;
10355                months = months.checked_add(n32.checked_mul(12)?)?;
10356            }
10357            _ => return None,
10358        }
10359        i += 2;
10360    }
10361    Some((months, days, micros))
10362}
10363
10364/// v7.12.4 — map a bare type-name identifier (the form that
10365/// appears in a function arg list or RETURNS clause) to a
10366/// [`ColumnTypeName`]. Returns `None` for unknown / extension
10367/// types so the caller can preserve them as
10368/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
10369///
10370/// Subset of the full column-type grammar — we deliberately
10371/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
10372/// here because function-arg types in v7.12.4 are mostly the
10373/// bare form (`text`, `int`, `bytea`, …).
10374fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
10375    Some(match ident.to_ascii_lowercase().as_str() {
10376        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
10377        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
10378        "bigint" => ColumnTypeName::BigInt,
10379        "float" | "double" | "real" => ColumnTypeName::Float,
10380        "text" => ColumnTypeName::Text,
10381        "bool" | "boolean" => ColumnTypeName::Bool,
10382        "date" => ColumnTypeName::Date,
10383        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
10384        "timestamptz" => ColumnTypeName::Timestamptz,
10385        "json" => ColumnTypeName::Json,
10386        "jsonb" => ColumnTypeName::Jsonb,
10387        "bytea" | "bytes" => ColumnTypeName::Bytes,
10388        "tsvector" => ColumnTypeName::TsVector,
10389        "tsquery" => ColumnTypeName::TsQuery,
10390        "uuid" => ColumnTypeName::Uuid,
10391        "interval" => ColumnTypeName::Interval,
10392        "time" => ColumnTypeName::Time,
10393        "year" => ColumnTypeName::Year,
10394        "timetz" => ColumnTypeName::TimeTz,
10395        "money" => ColumnTypeName::Money,
10396        _ => return None,
10397    })
10398}
10399
10400/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
10401/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
10402///
10403/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
10404/// / embedded SQL land in v7.12.5+):
10405///
10406/// ```text
10407///   body          := [ws] block [ws]
10408///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
10409///   stmt          := assign | return
10410///   assign        := assign_target := expr
10411///   assign_target := ( NEW | OLD ) . ident | ident
10412///   return        := RETURN ( NEW | OLD | NULL | expr )
10413/// ```
10414///
10415/// `expr` is parsed by recursing into the regular `Parser` — so a
10416/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
10417/// NEW.subject || ' ' || NEW.sender)` body shape works without
10418/// the body parser knowing what `to_tsvector` is.
10419///
10420/// Errors here cause the caller to fall back to
10421/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
10422/// successful, but the executor will refuse to invoke the
10423/// function with an "unparseable body" error.
10424/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
10425/// from the crate root as `spg_sql::parse_function_body`.
10426pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10427    parse_plpgsql_body(body)
10428}
10429
10430fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10431    // Use the regular lexer on the body text. The trailing
10432    // `END;` may or may not have a semicolon; the lexer treats
10433    // both forms identically.
10434    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
10435        message: alloc::format!("plpgsql body lex error: {e}"),
10436        token_pos: 0,
10437    })?;
10438    let mut parser = Parser::new(tokens);
10439    parser.parse_plpgsql_block()
10440}
10441
10442#[cfg(test)]
10443mod tests {
10444    use super::*;
10445    use alloc::string::ToString;
10446
10447    fn parse(s: &str) -> Statement {
10448        parse_statement(s).expect("parse ok")
10449    }
10450
10451    // v7.37.43-T4 sentori cutover acceptance — `release`, `index`,
10452    // `tables`, `partition`, etc. are unreserved keywords per PG's
10453    // `pg_get_keywords()` and MUST be usable as column / table /
10454    // alias names. Pre-T4 every drop-in user whose schema had one
10455    // of these as a column name (sentori events.release, mailrs
10456    // messages.index in some forks) blew the parser up at CREATE
10457    // TABLE time with "expected identifier, got Release". The
10458    // generalisation lives in `unreserved_keyword_text` + the
10459    // `expect_ident_like` and `parse_atom` arms that consult it.
10460    #[test]
10461    fn release_usable_as_column_name_in_create_table() {
10462        let stmt =
10463            parse("CREATE TABLE events (id INT PRIMARY KEY, release TEXT NOT NULL, payload TEXT)");
10464        if let Statement::CreateTable(t) = stmt {
10465            let names: alloc::vec::Vec<&str> = t.columns.iter().map(|c| c.name.as_str()).collect();
10466            assert_eq!(names, alloc::vec!["id", "release", "payload"]);
10467        } else {
10468            panic!("expected CreateTable");
10469        }
10470    }
10471
10472    #[test]
10473    fn release_usable_as_column_ref_in_select_projection() {
10474        // The sentori `0003_partition_events.sql` INSERT-SELECT
10475        // walk references `release` in both column lists; the
10476        // projection-side use exercises `parse_atom`'s relaxed
10477        // identifier set.
10478        parse("SELECT id, release, payload FROM events WHERE id = 1");
10479    }
10480
10481    #[test]
10482    fn release_usable_as_column_ref_in_insert_column_list() {
10483        // INSERT INTO t (id, release, payload) VALUES (…)
10484        parse("INSERT INTO events (id, release, payload) VALUES (1, '1.0.0', 'data')");
10485    }
10486
10487    #[test]
10488    fn alter_column_drop_not_null_uses_keyword_drop_token() {
10489        // Sentori `0013_audit_tombstone.sql` issues
10490        // `ALTER TABLE … ALTER COLUMN x DROP NOT NULL`. The lexer
10491        // emits Token::Drop (not Ident("drop")); the parser must
10492        // accept both in the ALTER COLUMN sub-dispatch.
10493        parse("ALTER TABLE audit_logs ALTER COLUMN org_id DROP NOT NULL");
10494    }
10495
10496    #[test]
10497    fn create_index_accepts_parenthesised_expression_key() {
10498        // sentori `0040_events_bundle_idx.sql` shape — JSONB
10499        // expression index. Pre-T4 the parser bailed at the
10500        // inner `(` with "expected column ident or expression,
10501        // got LParen". The Token::LParen arm in CREATE INDEX
10502        // routes through the expression parser instead.
10503        parse(
10504            "CREATE INDEX IF NOT EXISTS events_bundle_id_idx \
10505             ON events ((payload->'bundle'->>'id'))",
10506        );
10507    }
10508
10509    // v7.30.2 (mailrs round-25 ask 2) — nesting / chain budgets must
10510    // surface as parse errors, never stack overflows (embed hosts
10511    // abort on overflow).
10512    #[test]
10513    fn nesting_budget_errors_cleanly() {
10514        let depth = MAX_NEST_DEPTH + 50;
10515        let sql = format!("SELECT {}1{}", "(".repeat(depth), ")".repeat(depth));
10516        let err = parse_statement(&sql).expect_err("must reject");
10517        assert!(err.message.contains("nests deeper"), "{err:?}");
10518        // Within budget still parses.
10519        let sql = format!("SELECT {}1{}", "(".repeat(48), ")".repeat(48));
10520        parse(&sql);
10521    }
10522
10523    #[test]
10524    fn binary_chain_budget_errors_cleanly() {
10525        let sql = format!("SELECT 1{}", " + 1".repeat(MAX_BINARY_CHAIN + 50));
10526        let err = parse_statement(&sql).expect_err("must reject");
10527        assert!(err.message.contains("chained binary"), "{err:?}");
10528        // Within budget still parses (chain depth ≤ budget is safe
10529        // for recursive eval/drop on 2 MiB stacks).
10530        let sql = format!("SELECT 1{}", " + 1".repeat(200));
10531        parse(&sql);
10532    }
10533
10534    #[test]
10535    fn in_list_unaffected_by_chain_budget() {
10536        // Flat InList: 20k elements parse fine and stay flat.
10537        let items: alloc::vec::Vec<String> = (0..20_000).map(|k| k.to_string()).collect();
10538        let sql = format!("SELECT 1 WHERE 5 IN ({})", items.join(","));
10539        let Statement::Select(s) = parse(&sql) else {
10540            panic!("expected select")
10541        };
10542        let Some(Expr::InList { list, negated, .. }) = s.where_ else {
10543            panic!("expected flat InList, got {:?}", s.where_)
10544        };
10545        assert_eq!(list.len(), 20_000);
10546        assert!(!negated);
10547    }
10548
10549    fn lit_int(n: i64) -> Expr {
10550        Expr::Literal(Literal::Integer(n))
10551    }
10552
10553    fn col(name: &str) -> Expr {
10554        Expr::Column(ColumnName {
10555            qualifier: None,
10556            name: name.into(),
10557        })
10558    }
10559
10560    #[test]
10561    fn select_single_integer() {
10562        let s = parse("SELECT 1");
10563        let Statement::Select(s) = s else {
10564            panic!("expected SELECT")
10565        };
10566        assert_eq!(s.items.len(), 1);
10567        assert!(s.from.is_none());
10568        assert!(s.where_.is_none());
10569    }
10570
10571    #[test]
10572    fn select_multiple_literal_kinds() {
10573        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
10574        let Statement::Select(s) = s else {
10575            panic!("expected SELECT")
10576        };
10577        assert_eq!(s.items.len(), 5);
10578    }
10579
10580    #[test]
10581    fn select_wildcard_from_table() {
10582        let s = parse("SELECT * FROM users");
10583        let Statement::Select(s) = s else {
10584            panic!("expected SELECT")
10585        };
10586        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
10587        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
10588    }
10589
10590    #[test]
10591    fn select_with_table_alias() {
10592        let s = parse("SELECT * FROM users AS u");
10593        let Statement::Select(s) = s else {
10594            panic!("expected SELECT")
10595        };
10596        let t = &s.from.as_ref().unwrap().primary;
10597        assert_eq!(t.name, "users");
10598        assert_eq!(t.alias.as_deref(), Some("u"));
10599    }
10600
10601    #[test]
10602    fn select_with_where_eq() {
10603        let s = parse("SELECT a FROM t WHERE a = 1");
10604        let Statement::Select(s) = s else {
10605            panic!("expected SELECT")
10606        };
10607        let w = s.where_.unwrap();
10608        assert_eq!(
10609            w,
10610            Expr::Binary {
10611                lhs: Box::new(col("a")),
10612                op: BinOp::Eq,
10613                rhs: Box::new(lit_int(1)),
10614            }
10615        );
10616    }
10617
10618    #[test]
10619    fn arithmetic_precedence() {
10620        let s = parse("SELECT 1 + 2 * 3");
10621        let Statement::Select(s) = s else {
10622            panic!("expected SELECT")
10623        };
10624        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10625            panic!("wildcard?")
10626        };
10627        assert_eq!(
10628            expr,
10629            &Expr::Binary {
10630                lhs: Box::new(lit_int(1)),
10631                op: BinOp::Add,
10632                rhs: Box::new(Expr::Binary {
10633                    lhs: Box::new(lit_int(2)),
10634                    op: BinOp::Mul,
10635                    rhs: Box::new(lit_int(3)),
10636                }),
10637            }
10638        );
10639    }
10640
10641    #[test]
10642    fn parentheses_override_precedence() {
10643        let s = parse("SELECT (1 + 2) * 3");
10644        let Statement::Select(s) = s else {
10645            panic!("expected SELECT")
10646        };
10647        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10648            panic!()
10649        };
10650        assert_eq!(
10651            expr,
10652            &Expr::Binary {
10653                lhs: Box::new(Expr::Binary {
10654                    lhs: Box::new(lit_int(1)),
10655                    op: BinOp::Add,
10656                    rhs: Box::new(lit_int(2)),
10657                }),
10658                op: BinOp::Mul,
10659                rhs: Box::new(lit_int(3)),
10660            }
10661        );
10662    }
10663
10664    #[test]
10665    fn not_binds_below_comparison() {
10666        // `NOT a = 1` should parse as `NOT (a = 1)`.
10667        let s = parse("SELECT NOT a = 1 FROM t");
10668        let Statement::Select(s) = s else {
10669            panic!("expected SELECT")
10670        };
10671        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10672            panic!()
10673        };
10674        assert_eq!(
10675            expr,
10676            &Expr::Unary {
10677                op: UnOp::Not,
10678                expr: Box::new(Expr::Binary {
10679                    lhs: Box::new(col("a")),
10680                    op: BinOp::Eq,
10681                    rhs: Box::new(lit_int(1)),
10682                }),
10683            }
10684        );
10685    }
10686
10687    #[test]
10688    fn unary_minus_binds_above_multiplication() {
10689        // `-a * 2` should be `(-a) * 2`.
10690        let s = parse("SELECT -a * 2 FROM t");
10691        let Statement::Select(s) = s else {
10692            panic!("expected SELECT")
10693        };
10694        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10695            panic!()
10696        };
10697        assert_eq!(
10698            expr,
10699            &Expr::Binary {
10700                lhs: Box::new(Expr::Unary {
10701                    op: UnOp::Neg,
10702                    expr: Box::new(col("a")),
10703                }),
10704                op: BinOp::Mul,
10705                rhs: Box::new(lit_int(2)),
10706            }
10707        );
10708    }
10709
10710    #[test]
10711    fn qualified_column() {
10712        let s = parse("SELECT t.col FROM t");
10713        let Statement::Select(s) = s else {
10714            panic!("expected SELECT")
10715        };
10716        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10717            panic!()
10718        };
10719        assert_eq!(
10720            expr,
10721            &Expr::Column(ColumnName {
10722                qualifier: Some("t".into()),
10723                name: "col".into()
10724            })
10725        );
10726    }
10727
10728    #[test]
10729    fn select_item_alias_with_as() {
10730        let s = parse("SELECT a AS y FROM t");
10731        let Statement::Select(s) = s else {
10732            panic!("expected SELECT")
10733        };
10734        let SelectItem::Expr { alias, .. } = &s.items[0] else {
10735            panic!()
10736        };
10737        assert_eq!(alias.as_deref(), Some("y"));
10738    }
10739
10740    #[test]
10741    fn trailing_semicolon_accepted() {
10742        let s = parse("SELECT 1;");
10743        let Statement::Select(s) = s else {
10744            panic!("expected SELECT")
10745        };
10746        assert_eq!(s.items.len(), 1);
10747    }
10748
10749    #[test]
10750    fn boolean_chain_with_and_or_not() {
10751        // (NOT a) OR (b AND (NOT c))
10752        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
10753        let Statement::Select(s) = s else {
10754            panic!("expected SELECT")
10755        };
10756        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10757            panic!()
10758        };
10759        let expected = Expr::Binary {
10760            lhs: Box::new(Expr::Unary {
10761                op: UnOp::Not,
10762                expr: Box::new(col("a")),
10763            }),
10764            op: BinOp::Or,
10765            rhs: Box::new(Expr::Binary {
10766                lhs: Box::new(col("b")),
10767                op: BinOp::And,
10768                rhs: Box::new(Expr::Unary {
10769                    op: UnOp::Not,
10770                    expr: Box::new(col("c")),
10771                }),
10772            }),
10773        };
10774        assert_eq!(expr, &expected);
10775    }
10776
10777    #[test]
10778    fn empty_input_errors() {
10779        // v7.14.0 — pg_dump preambles emit several comment-only
10780        // / blank-line statements that collapse to Statement::
10781        // Empty rather than a parse error. The old "SELECT in
10782        // message" assertion is stale; verify the new contract:
10783        // empty / whitespace / comment-only input parses to
10784        // Statement::Empty.
10785        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
10786        assert!(matches!(
10787            parse_statement("  \n\t ").unwrap(),
10788            Statement::Empty
10789        ));
10790        // Sanity: malformed-but-non-empty still errors.
10791        assert!(parse_statement("SELECT FROM WHERE").is_err());
10792    }
10793
10794    #[test]
10795    fn unmatched_paren_errors() {
10796        assert!(parse_statement("SELECT (1 + 2").is_err());
10797    }
10798
10799    #[test]
10800    fn display_round_trip_simple_select() {
10801        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
10802        let text = original.to_string();
10803        let again = parse_statement(&text).expect("re-parse");
10804        assert_eq!(original, again);
10805    }
10806
10807    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
10808
10809    #[test]
10810    fn create_table_single_column() {
10811        let s = parse("CREATE TABLE foo (a INT)");
10812        let Statement::CreateTable(c) = s else {
10813            panic!("expected CreateTable")
10814        };
10815        assert_eq!(c.name, "foo");
10816        assert_eq!(c.columns.len(), 1);
10817        assert_eq!(c.columns[0].name, "a");
10818        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10819        assert!(c.columns[0].nullable);
10820    }
10821
10822    #[test]
10823    fn create_table_multi_column_with_not_null_mix() {
10824        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
10825        let Statement::CreateTable(c) = s else {
10826            panic!()
10827        };
10828        assert_eq!(c.columns.len(), 4);
10829        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10830        assert!(!c.columns[0].nullable);
10831        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
10832        assert!(c.columns[1].nullable);
10833        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
10834        assert!(!c.columns[2].nullable);
10835        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
10836    }
10837
10838    #[test]
10839    fn create_table_bigint_supported() {
10840        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
10841        let Statement::CreateTable(c) = s else {
10842            panic!()
10843        };
10844        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
10845    }
10846
10847    #[test]
10848    fn create_table_vector_default_is_f32() {
10849        let s = parse("CREATE TABLE t (v VECTOR(128))");
10850        let Statement::CreateTable(c) = s else {
10851            panic!()
10852        };
10853        assert_eq!(
10854            c.columns[0].ty,
10855            ColumnTypeName::Vector {
10856                dim: 128,
10857                encoding: VecEncoding::F32,
10858            },
10859        );
10860    }
10861
10862    #[test]
10863    fn create_table_vector_using_sq8() {
10864        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
10865        // Case-insensitive on both `USING` and the encoding name.
10866        for sql in [
10867            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
10868            "CREATE TABLE t (v VECTOR(128) using sq8)",
10869        ] {
10870            let s = parse(sql);
10871            let Statement::CreateTable(c) = s else {
10872                panic!()
10873            };
10874            assert_eq!(
10875                c.columns[0].ty,
10876                ColumnTypeName::Vector {
10877                    dim: 128,
10878                    encoding: VecEncoding::Sq8,
10879                },
10880                "{sql}",
10881            );
10882        }
10883    }
10884
10885    #[test]
10886    fn create_table_vector_using_unknown_errors() {
10887        // v7.16.1 — the inline `USING <encoding>` shape on
10888        // CREATE TABLE column defs was withdrawn before
10889        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
10890        // (col vector_<metric>_ops)`; the parser now rejects
10891        // USING at column-list position with a clearer
10892        // "expected ',' or ')'" message. Test asserts the
10893        // current rejection, not the old "unknown vector
10894        // encoding" string.
10895        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
10896        assert!(
10897            err.message.contains("USING")
10898                || err.message.contains("using")
10899                || err.message.contains("')'")
10900                || err.message.contains("','"),
10901            "expected USING/column-list rejection, got: {}",
10902            err.message
10903        );
10904    }
10905
10906    #[test]
10907    fn vector_using_sq8_display_roundtrips() {
10908        // The Display impl must produce text that re-parses to the
10909        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
10910        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
10911        let Statement::CreateTable(c) = s else {
10912            panic!()
10913        };
10914        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
10915    }
10916
10917    #[test]
10918    fn parser_recognises_placeholders() {
10919        use crate::ast::{Expr, SelectItem, Statement};
10920        // $N in expression position parses as Expr::Placeholder(N).
10921        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
10922        let Statement::Select(sel) = s else { panic!() };
10923        assert!(matches!(
10924            sel.items[0],
10925            SelectItem::Expr {
10926                expr: Expr::Placeholder(1),
10927                alias: None
10928            }
10929        ));
10930        // $2 + 1
10931        let SelectItem::Expr {
10932            expr: Expr::Binary { lhs, rhs, .. },
10933            ..
10934        } = &sel.items[1]
10935        else {
10936            panic!()
10937        };
10938        assert!(matches!(**lhs, Expr::Placeholder(2)));
10939        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
10940        // WHERE x = $3
10941        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
10942            panic!()
10943        };
10944        assert!(matches!(**rhs, Expr::Placeholder(3)));
10945    }
10946
10947    #[test]
10948    fn parser_rejects_dollar_zero() {
10949        // $0 is not valid in PG; the lexer rejects it.
10950        assert!(parse_statement("SELECT $0").is_err());
10951    }
10952
10953    #[test]
10954    fn placeholder_display_roundtrips() {
10955        // The Display impl must produce text that re-lexes to the
10956        // same Placeholder token.
10957        let s = parse("SELECT $42 FROM t");
10958        let printed = s.to_string();
10959        assert!(printed.contains("$42"));
10960        let again = parse(&printed);
10961        assert_eq!(s, again);
10962    }
10963
10964    #[test]
10965    fn alter_index_rebuild_bare() {
10966        use crate::ast::{AlterIndexTarget, Statement};
10967        let s = parse("ALTER INDEX my_idx REBUILD");
10968        let Statement::AlterIndex(a) = s else {
10969            panic!("expected AlterIndex, got {s:?}")
10970        };
10971        assert_eq!(a.name, "my_idx");
10972        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
10973    }
10974
10975    #[test]
10976    fn alter_index_rebuild_with_encoding() {
10977        use crate::ast::{AlterIndexTarget, Statement};
10978        for (sql, want) in [
10979            (
10980                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
10981                VecEncoding::F32,
10982            ),
10983            (
10984                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
10985                VecEncoding::Sq8,
10986            ),
10987            (
10988                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10989                VecEncoding::F16,
10990            ),
10991        ] {
10992            let s = parse(sql);
10993            let Statement::AlterIndex(a) = s else {
10994                panic!("{sql}: expected AlterIndex")
10995            };
10996            assert_eq!(a.name, "my_idx");
10997            assert_eq!(
10998                a.target,
10999                AlterIndexTarget::Rebuild {
11000                    encoding: Some(want)
11001                },
11002                "{sql}"
11003            );
11004        }
11005    }
11006
11007    #[test]
11008    fn alter_index_rebuild_unknown_encoding_errors() {
11009        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
11010        assert!(
11011            err.message.contains("unknown vector encoding"),
11012            "got: {}",
11013            err.message
11014        );
11015    }
11016
11017    #[test]
11018    fn alter_index_rebuild_display_roundtrips() {
11019        for (input, want) in [
11020            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
11021            (
11022                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
11023                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
11024            ),
11025            (
11026                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
11027                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
11028            ),
11029        ] {
11030            let s = parse(input);
11031            assert_eq!(s.to_string(), want);
11032        }
11033    }
11034
11035    #[test]
11036    fn create_table_unknown_type_defers_to_engine() {
11037        // v4.9 picked XML as a parse-time "unsupported column
11038        // type" probe. v7.17.0 Phase 1.4 changed the contract:
11039        // an unknown type ident parses as Text + `user_type_ref`
11040        // so CREATE TABLE can resolve user-defined enum / domain
11041        // types — rejection of truly-unknown types moved to the
11042        // engine's catalog lookup. v7.37.5 ζ-A then promoted XML
11043        // to a first-class built-in, so this probe switched to a
11044        // synthetic name nothing in the lexer will ever recognise.
11045        let stmt = parse_statement("CREATE TABLE x (a my_user_type)").unwrap();
11046        let Statement::CreateTable(t) = stmt else {
11047            panic!("expected CreateTable");
11048        };
11049        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("my_user_type"));
11050    }
11051
11052    #[test]
11053    fn create_table_missing_table_keyword_errors() {
11054        assert!(parse_statement("CREATE x (a INT)").is_err());
11055    }
11056
11057    // v7.37.6-B(sentori Epic 2 P0)— `PARTITION BY RANGE` parent +
11058    // `PARTITION OF parent <bounds>` child parse + Display round-trip.
11059
11060    #[test]
11061    fn parse_create_table_partition_by_range() {
11062        use crate::ast::{PartitionBySpec, PartitionKindAst};
11063        let stmt = parse_statement(
11064            "CREATE TABLE events_partitioned (id BIGINT NOT NULL, ts TIMESTAMPTZ NOT NULL, \
11065             payload JSONB) PARTITION BY RANGE (ts)",
11066        )
11067        .unwrap();
11068        let Statement::CreateTable(t) = stmt else {
11069            panic!("expected CreateTable");
11070        };
11071        assert!(t.partition_of.is_none(), "parent has no partition_of");
11072        assert_eq!(t.columns.len(), 3);
11073        let by = t.partition_by.as_ref().expect("expected PARTITION BY");
11074        assert_eq!(
11075            by,
11076            &PartitionBySpec {
11077                kind: PartitionKindAst::Range,
11078                key_columns: alloc::vec!["ts".to_string()],
11079            }
11080        );
11081        // Display round-trip preserves the suffix. `quote_ident`
11082        // only adds double quotes when the ident needs escaping, so
11083        // a plain `ts` survives bare here.
11084        assert!(
11085            t.to_string().contains("PARTITION BY RANGE (ts)"),
11086            "Display lost PARTITION BY suffix: {t}"
11087        );
11088    }
11089
11090    #[test]
11091    fn parse_create_table_partition_of_range() {
11092        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
11093        let stmt = parse_statement(
11094            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned \
11095             FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00')",
11096        )
11097        .unwrap();
11098        let Statement::CreateTable(t) = stmt else {
11099            panic!("expected CreateTable");
11100        };
11101        assert!(t.columns.is_empty(), "child inherits columns from parent");
11102        assert!(t.partition_by.is_none());
11103        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
11104        assert_eq!(of.parent_name, "events_partitioned");
11105        let PartitionOfSpec { bounds, .. } = of.clone();
11106        match bounds {
11107            PartitionOfBoundsAst::Range { lower, upper } => {
11108                assert!(lower.to_string().contains("2026-06-01"));
11109                assert!(upper.to_string().contains("2026-07-01"));
11110            }
11111            PartitionOfBoundsAst::Default => panic!("expected Range, got Default"),
11112        }
11113        // Display round-trip emits the FOR VALUES tail. `quote_ident`
11114        // skips quotes when not required, so the parent name appears
11115        // bare here.
11116        let s = t.to_string();
11117        assert!(
11118            s.contains("PARTITION OF events_partitioned"),
11119            "Display lost PARTITION OF: {s}"
11120        );
11121        assert!(s.contains("FOR VALUES FROM"), "Display lost FROM: {s}");
11122        assert!(s.contains(") TO ("), "Display lost TO: {s}");
11123    }
11124
11125    #[test]
11126    fn parse_create_table_partition_of_default() {
11127        use crate::ast::PartitionOfBoundsAst;
11128        let stmt =
11129            parse_statement("CREATE TABLE events_default PARTITION OF events_partitioned DEFAULT")
11130                .unwrap();
11131        let Statement::CreateTable(t) = stmt else {
11132            panic!("expected CreateTable");
11133        };
11134        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
11135        assert_eq!(of.parent_name, "events_partitioned");
11136        assert!(matches!(of.bounds, PartitionOfBoundsAst::Default));
11137        assert!(
11138            t.to_string()
11139                .contains("PARTITION OF events_partitioned DEFAULT"),
11140            "Display lost DEFAULT: {t}"
11141        );
11142    }
11143
11144    #[test]
11145    fn parse_create_table_partition_of_rejects_columns() {
11146        // v7.37.6-B contract: PARTITION OF children inherit columns
11147        // from the parent; an explicit list MUST surface as a parse
11148        // error rather than getting silently ignored.
11149        let err = parse_statement(
11150            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned (id BIGINT) \
11151             FOR VALUES FROM ('a') TO ('b')",
11152        );
11153        assert!(err.is_err(), "expected parse error for explicit columns");
11154        let msg = format!("{}", err.unwrap_err());
11155        assert!(
11156            msg.contains("PARTITION OF") && msg.contains("column"),
11157            "error should mention PARTITION OF + columns: {msg}"
11158        );
11159    }
11160
11161    #[test]
11162    fn insert_single_value() {
11163        let s = parse("INSERT INTO foo VALUES (42)");
11164        let Statement::Insert(i) = s else {
11165            panic!("expected Insert")
11166        };
11167        assert_eq!(i.table, "foo");
11168        assert_eq!(i.rows.len(), 1);
11169        assert_eq!(i.rows[0].len(), 1);
11170        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
11171    }
11172
11173    #[test]
11174    fn insert_multi_value_with_mixed_literals() {
11175        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
11176        let Statement::Insert(i) = s else { panic!() };
11177        assert_eq!(i.rows.len(), 1);
11178        assert_eq!(i.rows[0].len(), 5);
11179    }
11180
11181    #[test]
11182    fn insert_missing_into_errors() {
11183        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
11184    }
11185
11186    #[test]
11187    fn create_table_round_trip() {
11188        let original =
11189            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
11190        let text = original.to_string();
11191        let again = parse_statement(&text).expect("re-parse");
11192        assert_eq!(original, again);
11193    }
11194
11195    #[test]
11196    fn insert_round_trip_with_negation_and_string() {
11197        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
11198        let text = original.to_string();
11199        let again = parse_statement(&text).expect("re-parse");
11200        assert_eq!(original, again);
11201    }
11202
11203    #[test]
11204    fn unknown_keyword_at_statement_start_errors() {
11205        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
11206        // the top-level dispatch still has no branch to take.
11207        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
11208        assert!(err.message.contains("expected SELECT"));
11209    }
11210
11211    // --- v0.8 CREATE INDEX --------------------------------------------------
11212
11213    #[test]
11214    fn create_index_basic() {
11215        let s = parse("CREATE INDEX idx_id ON users (id)");
11216        let Statement::CreateIndex(c) = s else {
11217            panic!("expected CreateIndex")
11218        };
11219        assert_eq!(c.name, "idx_id");
11220        assert_eq!(c.table, "users");
11221        assert_eq!(c.column, "id");
11222    }
11223
11224    #[test]
11225    fn create_index_missing_on_errors() {
11226        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
11227    }
11228
11229    #[test]
11230    fn create_index_missing_paren_errors() {
11231        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
11232    }
11233
11234    #[test]
11235    fn create_index_round_trip() {
11236        let original = parse("CREATE INDEX by_name ON users (name)");
11237        let again = parse_statement(&original.to_string()).unwrap();
11238        assert_eq!(original, again);
11239    }
11240
11241    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
11242
11243    #[test]
11244    fn create_unique_index_basic() {
11245        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
11246        let Statement::CreateIndex(c) = s else {
11247            panic!("expected CreateIndex");
11248        };
11249        assert!(c.is_unique);
11250        assert_eq!(c.column, "a");
11251        assert!(c.partial_predicate.is_none());
11252    }
11253
11254    #[test]
11255    fn create_unique_index_partial() {
11256        // mailrs's email_templates "one default per user" shape.
11257        let s = parse(
11258            "CREATE UNIQUE INDEX idx_email_templates_user_default \
11259             ON email_templates (user_address) WHERE is_default = true",
11260        );
11261        let Statement::CreateIndex(c) = s else {
11262            panic!("expected CreateIndex");
11263        };
11264        assert!(c.is_unique);
11265        assert_eq!(c.table, "email_templates");
11266        assert_eq!(c.column, "user_address");
11267        assert!(c.partial_predicate.is_some());
11268    }
11269
11270    #[test]
11271    fn create_unique_index_composite_with_predicate() {
11272        // mailrs's calendar_events instance: composite columns.
11273        let s = parse(
11274            "CREATE UNIQUE INDEX uq_calendar_events_instance \
11275             ON calendar_events (calendar_id, uid, recurrence_id) \
11276             WHERE recurrence_id IS NOT NULL",
11277        );
11278        let Statement::CreateIndex(c) = s else {
11279            panic!("expected CreateIndex");
11280        };
11281        assert!(c.is_unique);
11282        assert_eq!(c.column, "calendar_id");
11283        assert_eq!(
11284            c.extra_columns,
11285            vec!["uid".to_string(), "recurrence_id".to_string()]
11286        );
11287        assert!(c.partial_predicate.is_some());
11288    }
11289
11290    #[test]
11291    fn create_unique_index_using_btree_ok() {
11292        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
11293        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
11294    }
11295
11296    #[test]
11297    fn create_unique_index_using_hnsw_rejected() {
11298        let err =
11299            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
11300        assert!(err.message.contains("UNIQUE"), "{}", err.message);
11301    }
11302
11303    #[test]
11304    fn create_unique_index_round_trip() {
11305        let original = parse(
11306            "CREATE UNIQUE INDEX uq_calendar_events_master \
11307             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
11308        );
11309        let again = parse_statement(&original.to_string()).unwrap();
11310        assert_eq!(original, again);
11311    }
11312
11313    #[test]
11314    fn create_unique_without_index_errors() {
11315        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
11316        assert!(err.message.contains("INDEX"), "{}", err.message);
11317    }
11318
11319    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
11320
11321    #[test]
11322    fn create_table_bytea_column() {
11323        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
11324        let Statement::CreateTable(c) = s else {
11325            panic!("expected CreateTable");
11326        };
11327        assert_eq!(c.columns.len(), 2);
11328        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
11329        assert!(!c.columns[1].nullable);
11330    }
11331
11332    #[test]
11333    fn create_table_bytes_alias_column() {
11334        let s = parse("CREATE TABLE t (blob BYTES)");
11335        let Statement::CreateTable(c) = s else {
11336            panic!("expected CreateTable");
11337        };
11338        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
11339    }
11340
11341    #[test]
11342    fn bytea_round_trip_display() {
11343        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
11344        let again = parse_statement(&original.to_string()).unwrap();
11345        assert_eq!(original, again);
11346    }
11347
11348    // --- v0.9 transactions -------------------------------------------------
11349
11350    #[test]
11351    fn begin_commit_rollback_parse_as_unit_variants() {
11352        assert_eq!(parse("BEGIN"), Statement::Begin);
11353        assert_eq!(parse("COMMIT"), Statement::Commit);
11354        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
11355        // Trailing semicolons accepted too.
11356        assert_eq!(parse("BEGIN;"), Statement::Begin);
11357    }
11358
11359    // --- v1.2: pgvector distance ops + ::vector cast --------------------
11360
11361    #[test]
11362    fn inner_product_binop_parses() {
11363        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
11364        let Statement::Select(s) = s else { panic!() };
11365        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11366            panic!()
11367        };
11368        assert!(matches!(
11369            expr,
11370            Expr::Binary {
11371                op: BinOp::InnerProduct,
11372                ..
11373            }
11374        ));
11375    }
11376
11377    #[test]
11378    fn cosine_distance_binop_parses() {
11379        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
11380        let Statement::Select(s) = s else { panic!() };
11381        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11382            panic!()
11383        };
11384        assert!(matches!(
11385            expr,
11386            Expr::Binary {
11387                op: BinOp::CosineDistance,
11388                ..
11389            }
11390        ));
11391    }
11392
11393    #[test]
11394    fn vector_cast_postfix_wraps_string_literal() {
11395        let s = parse("SELECT '[1,2,3]'::vector FROM t");
11396        let Statement::Select(s) = s else { panic!() };
11397        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11398            panic!()
11399        };
11400        assert!(matches!(
11401            expr,
11402            Expr::Cast {
11403                target: CastTarget::Vector,
11404                ..
11405            }
11406        ));
11407    }
11408
11409    #[test]
11410    fn unsupported_cast_target_errors() {
11411        // v7.37.5 ship triage promoted the parser to accept every
11412        // ident as a `CastTarget::Named(canonical)`; the engine
11413        // surfaces the "unsupported cast target" error at eval
11414        // time when `type_name_to_data_type` can't resolve it.
11415        // Parser-side error now requires a NON-ident after `::`
11416        // (e.g. a punctuation token).
11417        let err = parse_statement("SELECT 1::, FROM t").unwrap_err();
11418        assert!(err.message.contains("expected type ident after `::`"));
11419    }
11420
11421    #[test]
11422    fn tx_statements_round_trip() {
11423        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
11424            let original = parse(q);
11425            let again = parse_statement(&original.to_string()).unwrap();
11426            assert_eq!(original, again);
11427        }
11428    }
11429
11430    #[test]
11431    fn interval_text_parsing_units() {
11432        // v7.37.5 β — three-field shape `(months, days, micros)` so
11433        // `'1 day'` and `'24 hours'` no longer collide (PG parity).
11434        // Single unit.
11435        assert_eq!(parse_interval_text("1 day"), Some((0, 1, 0)));
11436        assert_eq!(
11437            parse_interval_text("24 hours"),
11438            Some((0, 0, 86_400_000_000))
11439        );
11440        assert_eq!(parse_interval_text("1 second"), Some((0, 0, 1_000_000)));
11441        assert_eq!(parse_interval_text("1 month"), Some((1, 0, 0)));
11442        assert_eq!(parse_interval_text("2 years"), Some((24, 0, 0)));
11443        assert_eq!(parse_interval_text("1 week"), Some((0, 7, 0)));
11444        // Compound spans accumulate per-dimension.
11445        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0, 0)));
11446        assert_eq!(
11447            parse_interval_text("1 day 2 hours"),
11448            Some((0, 1, 7_200_000_000))
11449        );
11450        // Negative numbers carry through per-dimension.
11451        assert_eq!(parse_interval_text("-1 day"), Some((0, -1, 0)));
11452        // Bad shapes return None.
11453        assert_eq!(parse_interval_text(""), None);
11454        assert_eq!(parse_interval_text("garbage"), None);
11455        assert_eq!(parse_interval_text("1 fortnight"), None);
11456        assert_eq!(parse_interval_text("1"), None);
11457    }
11458
11459    #[test]
11460    fn interval_literal_roundtrips_via_display() {
11461        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
11462        let s = parsed.to_string();
11463        // Display preserves the original text verbatim.
11464        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
11465        // And re-parsing yields a structurally equal statement.
11466        let again = parse_statement(&s).unwrap();
11467        assert_eq!(parsed, again);
11468    }
11469
11470    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
11471
11472    #[test]
11473    fn parser_recognises_create_publication_bare() {
11474        let s = parse("CREATE PUBLICATION pub_a");
11475        let Statement::CreatePublication(p) = s else {
11476            panic!("expected CreatePublication, got {s:?}")
11477        };
11478        assert_eq!(p.name, "pub_a");
11479        assert_eq!(p.scope, PublicationScope::AllTables);
11480    }
11481
11482    #[test]
11483    fn parser_recognises_create_publication_for_all_tables() {
11484        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
11485        let Statement::CreatePublication(p) = s else {
11486            panic!("expected CreatePublication, got {s:?}")
11487        };
11488        assert_eq!(p.name, "pub_a");
11489        assert_eq!(p.scope, PublicationScope::AllTables);
11490    }
11491
11492    #[test]
11493    fn parser_recognises_drop_publication() {
11494        let s = parse("DROP PUBLICATION pub_a");
11495        let Statement::DropPublication(name) = s else {
11496            panic!("expected DropPublication, got {s:?}")
11497        };
11498        assert_eq!(name, "pub_a");
11499    }
11500
11501    #[test]
11502    fn parser_recognises_for_table_list() {
11503        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
11504        let Statement::CreatePublication(p) = s else {
11505            panic!("expected CreatePublication, got {s:?}")
11506        };
11507        assert_eq!(p.name, "pub_a");
11508        let PublicationScope::ForTables(ts) = p.scope else {
11509            panic!("expected ForTables scope")
11510        };
11511        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
11512    }
11513
11514    #[test]
11515    fn parser_recognises_for_tables_plural() {
11516        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
11517        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
11518        let Statement::CreatePublication(p) = s else {
11519            panic!("expected CreatePublication, got {s:?}")
11520        };
11521        let PublicationScope::ForTables(ts) = p.scope else {
11522            panic!("expected ForTables")
11523        };
11524        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11525    }
11526
11527    #[test]
11528    fn parser_recognises_for_all_tables_except_list() {
11529        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
11530        let Statement::CreatePublication(p) = s else {
11531            panic!()
11532        };
11533        let PublicationScope::AllTablesExcept(ts) = p.scope else {
11534            panic!("expected AllTablesExcept")
11535        };
11536        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11537    }
11538
11539    #[test]
11540    fn parser_rejects_for_table_with_empty_list() {
11541        // `FOR TABLE` with nothing after is a parse error.
11542        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
11543            .expect_err("must error on empty list");
11544        // No specific message asserted — the call falls through to
11545        // expect_ident_like which yields "expected identifier, got …".
11546        assert!(!err.message.is_empty());
11547    }
11548
11549    #[test]
11550    fn parser_recognises_show_publications() {
11551        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
11552        // bare ident in this position, NOT a reserved keyword.
11553        let s = parse("SHOW PUBLICATIONS");
11554        assert!(matches!(s, Statement::ShowPublications));
11555    }
11556
11557    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
11558
11559    #[test]
11560    fn parser_recognises_create_subscription_single_publication() {
11561        let s = parse(
11562            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
11563        );
11564        let Statement::CreateSubscription(c) = s else {
11565            panic!("expected CreateSubscription, got {s:?}")
11566        };
11567        assert_eq!(c.name, "sub_a");
11568        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
11569        assert_eq!(c.publications, alloc::vec!["pub_a"]);
11570    }
11571
11572    #[test]
11573    fn parser_recognises_create_subscription_multi_publication() {
11574        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
11575        let Statement::CreateSubscription(c) = s else {
11576            panic!()
11577        };
11578        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
11579    }
11580
11581    #[test]
11582    fn parser_rejects_create_subscription_missing_connection() {
11583        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
11584            .expect_err("must error on missing CONNECTION");
11585        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
11586    }
11587
11588    #[test]
11589    fn parser_rejects_create_subscription_missing_publication() {
11590        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
11591            .expect_err("must error on missing PUBLICATION");
11592        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
11593    }
11594
11595    #[test]
11596    fn parser_recognises_drop_subscription() {
11597        let s = parse("DROP SUBSCRIPTION sub_a");
11598        let Statement::DropSubscription(name) = s else {
11599            panic!("expected DropSubscription, got {s:?}")
11600        };
11601        assert_eq!(name, "sub_a");
11602    }
11603
11604    #[test]
11605    fn parser_recognises_show_subscriptions() {
11606        let s = parse("SHOW SUBSCRIPTIONS");
11607        assert!(matches!(s, Statement::ShowSubscriptions));
11608    }
11609
11610    #[test]
11611    fn parser_recognises_wait_for_wal_position_no_timeout() {
11612        let s = parse("WAIT FOR WAL POSITION 12345");
11613        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11614            panic!("expected WaitForWalPosition, got {s:?}")
11615        };
11616        assert_eq!(pos, 12345);
11617        assert!(timeout_ms.is_none());
11618    }
11619
11620    #[test]
11621    fn parser_recognises_wait_for_wal_position_with_timeout() {
11622        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
11623        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11624            panic!()
11625        };
11626        assert_eq!(pos, 67890);
11627        assert_eq!(timeout_ms, Some(5000));
11628    }
11629
11630    #[test]
11631    fn parser_rejects_wait_with_negative_position() {
11632        // The lexer treats `-` as a token; `expect_u64_literal`
11633        // only sees the Integer that follows, so the negative
11634        // arrives as a unary-minus expression at higher levels.
11635        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
11636        // parse error one way or another.
11637        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
11638        assert!(!err.message.is_empty());
11639    }
11640
11641    #[test]
11642    fn parser_recognises_bare_analyze() {
11643        let s = parse("ANALYZE");
11644        assert!(matches!(s, Statement::Analyze(None)));
11645    }
11646
11647    #[test]
11648    fn parser_recognises_analyze_with_table() {
11649        let s = parse("ANALYZE users");
11650        let Statement::Analyze(Some(name)) = s else {
11651            panic!("expected Analyze, got {s:?}")
11652        };
11653        assert_eq!(name, "users");
11654    }
11655
11656    #[test]
11657    fn parser_recognises_analyze_with_quoted_table() {
11658        let s = parse("ANALYZE \"Mixed Case\"");
11659        let Statement::Analyze(Some(name)) = s else {
11660            panic!()
11661        };
11662        assert_eq!(name, "Mixed Case");
11663    }
11664
11665    #[test]
11666    fn parser_rejects_analyze_with_garbage_token() {
11667        let err = parse_statement("ANALYZE 42").expect_err("must error");
11668        assert!(!err.message.is_empty());
11669    }
11670
11671    #[test]
11672    fn analyze_display_roundtrips() {
11673        for sql in ["ANALYZE", "ANALYZE users"] {
11674            let s = parse(sql);
11675            let printed = s.to_string();
11676            let again = parse_statement(&printed)
11677                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11678            assert_eq!(s, again);
11679        }
11680    }
11681
11682    #[test]
11683    fn wait_for_display_roundtrips() {
11684        for sql in [
11685            "WAIT FOR WAL POSITION 12345",
11686            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
11687        ] {
11688            let s = parse(sql);
11689            let printed = s.to_string();
11690            let again = parse_statement(&printed)
11691                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11692            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11693        }
11694    }
11695
11696    #[test]
11697    fn subscription_ddl_display_roundtrips() {
11698        for sql in [
11699            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
11700            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
11701            "DROP SUBSCRIPTION sub_a",
11702            "SHOW SUBSCRIPTIONS",
11703        ] {
11704            let s = parse(sql);
11705            let printed = s.to_string();
11706            let again = parse_statement(&printed)
11707                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11708            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11709        }
11710    }
11711
11712    #[test]
11713    fn parser_drop_dispatches_user_vs_publication() {
11714        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
11715        // tokenises DROP. Both targets must still parse.
11716        let s = parse("DROP USER 'alice'");
11717        let Statement::DropUser(name) = s else {
11718            panic!("expected DropUser, got {s:?}")
11719        };
11720        assert_eq!(name, "alice");
11721        // And DROP PUBLICATION lands the new variant.
11722        let s = parse("DROP PUBLICATION p1");
11723        assert!(matches!(s, Statement::DropPublication(_)));
11724    }
11725
11726    #[test]
11727    fn publication_ddl_display_roundtrips() {
11728        // Every CREATE PUBLICATION variant must Display → parse →
11729        // same AST. v6.1.3 covers all three scope shapes.
11730        for sql in [
11731            "CREATE PUBLICATION pub_a",
11732            "CREATE PUBLICATION pub_a FOR ALL TABLES",
11733            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
11734            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
11735            "DROP PUBLICATION pub_a",
11736            "SHOW PUBLICATIONS",
11737        ] {
11738            let s = parse(sql);
11739            let printed = s.to_string();
11740            let again = parse_statement(&printed)
11741                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11742            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11743        }
11744    }
11745
11746    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
11747
11748    #[test]
11749    fn create_function_returns_trigger_plpgsql_minimal() {
11750        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
11751        let s = parse(sql);
11752        let Statement::CreateFunction(f) = s else {
11753            panic!("expected CreateFunction");
11754        };
11755        assert_eq!(f.name, "noop");
11756        assert!(!f.or_replace);
11757        assert!(f.args.is_empty());
11758        assert!(matches!(f.returns, FunctionReturn::Trigger));
11759        assert_eq!(f.language, "plpgsql");
11760        let FunctionBody::PlPgSql(block) = f.body else {
11761            panic!("expected PlPgSql body");
11762        };
11763        assert_eq!(block.statements.len(), 1);
11764        assert!(matches!(
11765            block.statements[0],
11766            PlPgSqlStmt::Return(ReturnTarget::New)
11767        ));
11768    }
11769
11770    #[test]
11771    fn create_function_or_replace_with_assignment() {
11772        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
11773        // RETURN NEW.
11774        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
11775BEGIN
11776  NEW.search_vector := to_tsvector('english', NEW.subject);
11777  RETURN NEW;
11778END;
11779$$";
11780        let s = parse(sql);
11781        let Statement::CreateFunction(f) = s else {
11782            panic!("expected CreateFunction");
11783        };
11784        assert!(f.or_replace);
11785        let FunctionBody::PlPgSql(block) = &f.body else {
11786            panic!("expected PlPgSql body");
11787        };
11788        assert_eq!(block.statements.len(), 2);
11789        // First statement: NEW.search_vector := to_tsvector(...)
11790        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
11791            panic!("expected Assign as first stmt");
11792        };
11793        match target {
11794            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
11795            other => panic!("expected NEW.col, got {other:?}"),
11796        }
11797        // Second statement: RETURN NEW
11798        assert!(matches!(
11799            block.statements[1],
11800            PlPgSqlStmt::Return(ReturnTarget::New)
11801        ));
11802    }
11803
11804    #[test]
11805    fn create_trigger_after_insert_or_update() {
11806        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
11807        let s = parse(sql);
11808        let Statement::CreateTrigger(t) = s else {
11809            panic!("expected CreateTrigger");
11810        };
11811        assert_eq!(t.name, "tg");
11812        assert_eq!(t.table, "messages");
11813        assert_eq!(t.timing, TriggerTiming::After);
11814        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
11815        assert_eq!(t.for_each, TriggerForEach::Row);
11816        assert_eq!(t.function, "update_sv");
11817    }
11818
11819    #[test]
11820    fn create_trigger_before_delete_execute_procedure_alias() {
11821        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
11822        let sql =
11823            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
11824        let s = parse(sql);
11825        let Statement::CreateTrigger(t) = s else {
11826            panic!("expected CreateTrigger");
11827        };
11828        assert_eq!(t.timing, TriggerTiming::Before);
11829        assert_eq!(t.events, vec![TriggerEvent::Delete]);
11830    }
11831
11832    #[test]
11833    fn drop_trigger_if_exists_round_trips() {
11834        // No parser support for DROP TRIGGER yet — added in v7.12.5
11835        // alongside the broader DROP …{IF EXISTS} cleanup. The
11836        // AST + Display impls are in place so we round-trip via
11837        // construction:
11838        let s = Statement::DropTrigger {
11839            name: "tg".into(),
11840            table: "messages".into(),
11841            if_exists: true,
11842        };
11843        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
11844    }
11845
11846    #[test]
11847    fn trigger_ddl_display_roundtrips_through_parser() {
11848        // CREATE TRIGGER + its referenced CREATE FUNCTION must
11849        // Display → parse → same AST (modulo PL/pgSQL body
11850        // formatting which is parser-canonicalised).
11851        for sql in [
11852            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
11853            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
11854        ] {
11855            let s = parse(sql);
11856            let printed = s.to_string();
11857            let again = parse_statement(&printed)
11858                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11859            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11860        }
11861    }
11862}