Skip to main content

spg_sql/
parser.rs

1//! Recursive-descent parser with a Pratt (precedence-climbing) sub-parser for
2//! expressions.
3//!
4//! Precedence (lowest → highest binding):
5//! `OR` (1) `<` `AND` (2) `<` `NOT` unary (3) `<`
6//! comparisons `=` `<>` `<` `<=` `>` `>=` (4) `<`
7//! `+` `-` (5) `<` `*` `/` (6) `<` unary `-` (7) `<` parens / atom.
8//!
9//! This matches PG's behaviour for the operators we support — e.g. `NOT a = b`
10//! parses as `NOT (a = b)` and `-a * b` as `(-a) * b`.
11
12use alloc::boxed::Box;
13use alloc::format;
14use alloc::string::{String, ToString};
15use alloc::vec;
16use alloc::vec::Vec;
17use core::fmt;
18use core::mem;
19
20use crate::ast::{
21    AssignTarget, BinOp, CastTarget, Collation, ColumnDef, ColumnName, ColumnTypeName,
22    CreateFunctionStatement, CreateIndexStatement, CreatePublicationStatement,
23    CreateSubscriptionStatement, CreateTableStatement, CreateTriggerStatement, Expr, ExtractField,
24    FkAction, ForeignKeyConstraint, FrameBound, FrameKind, FromClause, FromJoin, FunctionArg,
25    FunctionArgMode, FunctionArgType, FunctionBody, FunctionReturn, IndexMethod, InsertStatement,
26    JoinKind, Literal, NullTreatment, OrderBy, PlPgSqlBlock, PlPgSqlDeclare, PlPgSqlStmt,
27    PublicationScope, RaiseLevel, RangeKindAst, ReturnTarget, SelectItem, SelectStatement,
28    Statement, TableRef, TriggerEvent, TriggerForEach, TriggerTiming, UnOp, UnionKind, VecEncoding,
29    WindowFrame,
30};
31use crate::lexer::{self, LexError, Token};
32
33/// v7.14.0 — true when the leading keyword of a top-level
34/// statement is one of the dump-emitted DDL forms SPG accepts
35/// as a no-op (no behavioural effect on the single-schema /
36/// single-database model). These statements are consumed up to
37/// the next `;` / EOF and returned as `Statement::Empty`.
38fn is_dump_noise_statement(lc: &str) -> bool {
39    matches!(
40        lc,
41        // Object comments / privileges / ownership — none of
42        // these change schema semantics on SPG.
43        "comment"
44            | "grant"
45            | "revoke"
46            // MySQL bulk-load brackets.
47            | "lock"
48            | "unlock"
49            // MySQL OPTIMIZE / ANALYZE TABLE / CHECK TABLE
50            // diagnostics that pg_dump-style tools also emit
51            // post-restore.
52            | "optimize"
53            | "check"
54            | "use"
55            // PG psql backslash meta-commands that newer
56            // pg_dump versions emit unescaped (\restrict /
57            // \unrestrict). Real psql intercepts these; SPG's
58            // PG-wire sees them as raw text.
59            | "\\restrict"
60            | "\\unrestrict"
61            // v7.17.0 Phase 4.1 — MySQL `DELIMITER //` and
62            // `DELIMITER ;` directives. Technically client-side
63            // (the `mysql` CLI uses them to set the statement
64            // terminator), not SQL — but mysqldump and stored-
65            // procedure scripts emit them inline. SPG's parser
66            // sees one statement at a time and doesn't care
67            // about the terminator, so consume DELIMITER lines
68            // as Empty.
69            | "delimiter"
70    )
71}
72
73/// v7.37.43-T4 — PG-unreserved keywords that are legal identifiers
74/// per `pg_get_keywords()`. SPG tokenizes these as named variants
75/// so the parser can dispatch on them in their owning contexts
76/// (`RELEASE SAVEPOINT`, `SHOW name`, `BEGIN`/`COMMIT`/`ROLLBACK`,
77/// `CREATE INDEX`, etc.), but they MUST stay usable as table /
78/// column / alias names — that's the PG contract for unreserved
79/// keywords (see PG docs Appendix C.1).
80///
81/// Before this generalisation, sentori migration 0001_init.sql
82/// `release TEXT NOT NULL` blew up the parser with "expected
83/// identifier, got Release", and the same gap stalked every
84/// SPG drop-in user whose schema had a column / alias named
85/// `release` / `index` / `tables` / `show` / `savepoint` /
86/// `begin` / `commit` / `rollback` / `drop` / `insert` / `values`
87/// / `limit` / `partition`. PG accepts all of them as identifiers
88/// when unquoted, so SPG must too.
89///
90/// Returns the canonical lowercase identifier text when the token
91/// belongs to PG's unreserved class, `None` otherwise. Used by
92/// `expect_ident_like` (column / table / alias names) so the
93/// generalisation applies everywhere an identifier may appear,
94/// not just in the contexts these tokens were introduced for.
95fn unreserved_keyword_text(tok: &Token) -> Option<String> {
96    let s = match tok {
97        // PG keyword class: unreserved or col_name.
98        Token::Release => "release",
99        Token::Savepoint => "savepoint",
100        Token::Show => "show",
101        Token::Index => "index",
102        Token::Begin => "begin",
103        Token::Commit => "commit",
104        Token::Rollback => "rollback",
105        Token::Drop => "drop",
106        Token::Insert => "insert",
107        Token::Values => "values",
108        Token::Limit => "limit",
109        Token::Partition => "partition",
110        Token::Tables => "tables",
111        Token::Connection => "connection",
112        Token::Publication => "publication",
113        Token::Subscription => "subscription",
114        Token::Interval => "interval",
115        // `extract` is non-reserved in PG too (it's a function the
116        // parser dispatches via context — outside that context it's
117        // a plain identifier).
118        Token::Extract => "extract",
119        Token::Offset => "offset",
120        // `to` is reserved in PG (used in many "AS … TO …" forms), so
121        // it is NOT relaxed here. Same for `from`, `where`, `as`,
122        // `select`, `not`, `and`, `or`, `null`, `true`, `false`,
123        // `create`, `table`, `into`, `on`, `order`, `by`, `having`,
124        // `group`, `distinct`, `union`, `all`, `join`, `inner`,
125        // `left`, `cross`, `outer`, `default`, `is`, `between`,
126        // `in`, `like`, `for`, `except`, `desc`, `asc`, `partition`
127        // (partial — keep partition as unreserved per modern PG).
128        _ => return None,
129    };
130    Some(s.to_string())
131}
132
133/// v7.9.22 — recognise pgvector / SPG vector-index opclass names
134/// in CREATE INDEX. SPG's HNSW already routes by query operator;
135/// the opclass is accepted for `pg_dump` compatibility (mailrs
136/// migration follow-up G5).
137/// v7.13.0 — extended to recognise PG built-in / pg_trgm opclasses
138/// (mailrs round-5 G5). These are tokens-only acceptance — SPG
139/// doesn't change index behaviour based on them.
140fn is_vector_opclass_name(name: &str) -> bool {
141    let lc = name.to_ascii_lowercase();
142    matches!(
143        lc.as_str(),
144        "vector_cosine_ops"
145            | "vector_l2_ops"
146            | "vector_ip_ops"
147            | "halfvec_cosine_ops"
148            | "halfvec_l2_ops"
149            | "halfvec_ip_ops"
150            | "sq8_cosine_ops"
151            | "sq8_l2_ops"
152            | "sq8_ip_ops"
153            // pg_trgm — trigram operator class. SPG's GIN index
154            // already uses tsvector tokens; trigram-style LIKE
155            // pattern matching still routes through a sequential
156            // scan, but the opclass name is accepted so PG schemas
157            // load.
158            | "gin_trgm_ops"
159            | "gist_trgm_ops"
160            // PG built-in btree opclasses occasionally appear in
161            // pg_dump output for column types with multiple
162            // sort orders (text_pattern_ops, varchar_pattern_ops,
163            // bpchar_pattern_ops).
164            | "text_pattern_ops"
165            | "varchar_pattern_ops"
166            | "bpchar_pattern_ops"
167            | "int4_ops"
168            | "int8_ops"
169            | "text_ops"
170    )
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct ParseError {
175    pub message: String,
176    /// Index into the token stream where parsing tripped. Not a byte offset.
177    pub token_pos: usize,
178}
179
180impl fmt::Display for ParseError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(
183            f,
184            "parse error at token #{}: {}",
185            self.token_pos, self.message
186        )
187    }
188}
189
190impl From<LexError> for ParseError {
191    fn from(e: LexError) -> Self {
192        Self {
193            message: format!("lex: {e}"),
194            token_pos: 0,
195        }
196    }
197}
198
199/// v7.9.30 — parse a single expression (no trailing junk). Used by
200/// the engine to re-hydrate stored partial-index / unique-index
201/// predicates from their canonical Display form. The same Pratt
202/// parser the statement path uses; this entry point just skips the
203/// statement dispatch.
204pub fn parse_expression(input: &str) -> Result<Expr, ParseError> {
205    let tokens = lexer::tokenize(input)?;
206    let mut p = Parser::new(tokens);
207    let expr = p.parse_expr(0)?;
208    p.expect_eof()?;
209    Ok(expr)
210}
211
212/// Parse exactly one statement, swallow an optional trailing `;`, and require
213/// the token stream to end there. PG string semantics.
214pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
215    parse_statement_with(input, false)
216}
217
218/// v7.22 (round-13 T3) — dialect-aware entry: `backslash_escapes`
219/// selects MySQL-style string lexing (see `lexer::tokenize_with`).
220/// The engine threads its session flag through here.
221pub fn parse_statement_with(input: &str, backslash_escapes: bool) -> Result<Statement, ParseError> {
222    let tokens = lexer::tokenize_with(input, backslash_escapes)?;
223    let mut p = Parser::new(tokens);
224    let stmt = p.parse_one_statement()?;
225    if matches!(p.peek(), Token::Semicolon) {
226        p.advance();
227    }
228    p.expect_eof()?;
229    Ok(stmt)
230}
231
232struct Parser {
233    tokens: Vec<Token>,
234    pos: usize,
235    /// v7.30.2 (mailrs round-25 ask 2) — live nesting depth of the
236    /// mutually recursive expr/select parsers. Bounded so a deeply
237    /// nested input returns a parse error instead of overflowing
238    /// the stack (embed hosts die on overflow — it is an abort,
239    /// not a catchable error).
240    nest_depth: usize,
241}
242
243/// Max expr/select parser nesting (parens, subqueries, CASE, …).
244/// Real SQL nests a few dozen levels at the extreme. Each nesting
245/// level costs a parse_expr→parse_unary→parse_atom frame chain —
246/// over 10 KiB in debug builds (parse_atom is a giant match) — so
247/// 64 is the highest budget that stays comfortably inside a 2 MiB
248/// worker stack in BOTH debug and release builds.
249const MAX_NEST_DEPTH: usize = 64;
250
251/// Max consecutive binary operators at ONE precedence level
252/// (`a OR b OR c …`, `1+1+1…`). The chain builds iteratively at
253/// parse time but evaluates and drops recursively — depth beyond
254/// this overflows 2 MiB worker stacks (debug eval frames run
255/// multiple KiB). `IN (…)` lists are flat and unaffected.
256const MAX_BINARY_CHAIN: usize = 256;
257
258/// v7.22 (round-13 gap 5) — the kind keyword after `CONSTRAINT
259/// <name>` in a CREATE TABLE column list. FOREIGN KEY is not here:
260/// it keeps its dedicated path (`parse_table_level_fk`).
261enum NamedTableConstraintKind {
262    Check,
263    Unique,
264    PrimaryKey,
265}
266
267impl Parser {
268    fn new(tokens: Vec<Token>) -> Self {
269        Self {
270            tokens,
271            pos: 0,
272            nest_depth: 0,
273        }
274    }
275
276    /// v7.30.2 (mailrs round-25 ask 2) — bump the expr/select
277    /// nesting depth, erroring out cleanly past the budget.
278    fn enter_nested(&mut self) -> Result<(), ParseError> {
279        self.nest_depth += 1;
280        if self.nest_depth > MAX_NEST_DEPTH {
281            self.nest_depth -= 1;
282            return Err(self.err(alloc::format!(
283                "statement nests deeper than {MAX_NEST_DEPTH} levels"
284            )));
285        }
286        Ok(())
287    }
288
289    fn peek(&self) -> &Token {
290        // tokens always ends with Eof; pos is clamped in advance().
291        &self.tokens[self.pos]
292    }
293
294    fn advance(&mut self) -> Token {
295        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
296        if self.pos + 1 < self.tokens.len() {
297            self.pos += 1;
298        }
299        t
300    }
301
302    fn err(&self, message: String) -> ParseError {
303        ParseError {
304            message,
305            token_pos: self.pos,
306        }
307    }
308
309    fn expect_eof(&self) -> Result<(), ParseError> {
310        if matches!(self.peek(), Token::Eof) {
311            Ok(())
312        } else {
313            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
314        }
315    }
316
317    /// v7.14.0 — swallow every token up to (but not including) the
318    /// next semicolon / EOF. Used by the dump-noise dispatcher
319    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
320    /// etc. without modeling each grammar.
321    fn consume_until_statement_boundary(&mut self) {
322        loop {
323            match self.peek() {
324                Token::Semicolon | Token::Eof => return,
325                _ => self.advance(),
326            };
327        }
328    }
329
330    /// v7.22 (round-13 T2) — consume to the statement boundary like
331    /// `consume_until_statement_boundary`, but pick out the sequence
332    /// name on the way: either `SEQUENCE NAME <ident>` (identity
333    /// columns) or the first string literal (`nextval('<seq>')`).
334    /// Schema qualifiers and `::regclass` casts are stripped.
335    fn scan_sequence_name_until_boundary(&mut self) -> Option<String> {
336        let mut seq: Option<String> = None;
337        let mut after_sequence_kw = false;
338        let mut after_name_kw = false;
339        loop {
340            match self.peek().clone() {
341                Token::Semicolon | Token::Eof => break,
342                Token::Ident(s) | Token::QuotedIdent(s) => {
343                    if after_name_kw && seq.is_none() {
344                        self.advance();
345                        let mut name = s;
346                        // `SEQUENCE NAME public.groups_id_seq` — keep
347                        // the bare name, drop qualifiers.
348                        while matches!(self.peek(), Token::Dot) {
349                            self.advance();
350                            if let Token::Ident(n) | Token::QuotedIdent(n) = self.advance() {
351                                name = n;
352                            }
353                        }
354                        seq = Some(name);
355                        after_name_kw = false;
356                        continue;
357                    }
358                    if after_sequence_kw && s.eq_ignore_ascii_case("name") {
359                        after_name_kw = true;
360                        after_sequence_kw = false;
361                    } else {
362                        after_sequence_kw = s.eq_ignore_ascii_case("sequence");
363                    }
364                    self.advance();
365                }
366                Token::String(s) => {
367                    if seq.is_none() {
368                        // `nextval('public.groups_id_seq'::regclass)`
369                        let bare = s
370                            .rsplit_once('.')
371                            .map_or_else(|| s.clone(), |(_, b)| b.to_string());
372                        seq = Some(bare);
373                    }
374                    self.advance();
375                }
376                _ => {
377                    after_sequence_kw = false;
378                    after_name_kw = false;
379                    self.advance();
380                }
381            }
382        }
383        seq
384    }
385
386    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
387        let first = match self.advance() {
388            Token::Ident(s) | Token::QuotedIdent(s) => s,
389            // v7.37.43-T4 — PG-unreserved keywords are legal identifiers
390            // per PG's `pg_get_keywords()` classification. SPG tokenizes
391            // these as named variants for parsing leverage in the
392            // contexts that own them (`RELEASE SAVEPOINT`, `SHOW name`,
393            // `BEGIN`, etc.), but they MUST still be usable as table /
394            // column / alias names in DDL+DML. Sentori migrations like
395            // 0001_init.sql ship `release TEXT NOT NULL` in the events
396            // table — the `events.release` column carries the release
397            // identifier string. Pre-T4 this triggered "expected
398            // identifier, got Release" and blocked every drop-in user
399            // whose schema had a column / alias with one of these names.
400            other if unreserved_keyword_text(&other).is_some() => {
401                unreserved_keyword_text(&other).unwrap()
402            }
403            other => {
404                return Err(ParseError {
405                    message: format!("expected identifier, got {other:?}"),
406                    token_pos: self.pos.saturating_sub(1),
407                });
408            }
409        };
410        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
411        // qualify every name with `public.` (and pg_catalog.* for
412        // functions); SPG is single-schema so we discard the
413        // prefix and return only the trailing ident. Same shape
414        // also handles MySQL `db.tbl` cross-database refs (SPG
415        // ignores the db part).
416        if matches!(self.peek(), Token::Dot) {
417            self.advance();
418            match self.advance() {
419                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
420                other if unreserved_keyword_text(&other).is_some() => {
421                    return Ok(unreserved_keyword_text(&other).unwrap());
422                }
423                other => {
424                    return Err(ParseError {
425                        message: format!("expected identifier after '{first}.', got {other:?}"),
426                        token_pos: self.pos.saturating_sub(1),
427                    });
428                }
429            }
430        }
431        Ok(first)
432    }
433
434    #[allow(clippy::too_many_lines)]
435    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
436        // v7.14.0 — empty / comment-only / semicolon-only input
437        // (after the lexer strips line + block + MySQL
438        // conditional comments) lands as Statement::Empty.
439        // pg_dump and mysqldump emit several wrappers that
440        // collapse to nothing after stripping (`/*!40101 SET …
441        // */;`, blank lines between statements); the engine
442        // returns CommandOk no-op so the dump loads cleanly.
443        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
444            return Ok(Statement::Empty);
445        }
446        // v7.14.0 — pg_dump / mysqldump "noise" statements:
447        // catalog / metadata DDL that has no behavioural effect
448        // on SPG's single-schema, single-database, single-user
449        // model. Consume the whole statement up to the next
450        // semicolon / EOF and return Empty. This is broader than
451        // the per-keyword DROP / SET / COMMENT arms but lets the
452        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
453        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
454        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
455        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
456            let lc = s.to_ascii_lowercase();
457            if is_dump_noise_statement(&lc) {
458                self.consume_until_statement_boundary();
459                return Ok(Statement::Empty);
460            }
461        }
462        match self.peek() {
463            Token::Select => self.parse_select_stmt(),
464            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
465            // body is a dollar-quoted plpgsql block (lexer already
466            // collapsed `$$…$$` into a single Token::String).
467            // v7.16.2 — mailrs round-10 A.2: parse the body as a
468            // real PlPgSqlBlock so the engine can EXECUTE it at
469            // top level instead of silently swallowing. Pre-
470            // v7.16.2 the parser threw the body away and the
471            // engine returned CommandOk for the entire DO; that
472            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
473            // $$` into a SEV-1 silent no-op (the IF + the rename
474            // were both invisible — mailrs's migrate-042 didn't
475            // actually run). Now the body parses + executes;
476            // EmbeddedSql inside the block runs immediately
477            // against the engine (not deferred — we're at top
478            // level, not inside a trigger row-write loop).
479            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
480                self.advance();
481                let body_text = match self.advance() {
482                    Token::String(s) => s,
483                    other => {
484                        return Err(self.err(alloc::format!(
485                            "expected dollar-quoted body after DO, got {other:?}"
486                        )));
487                    }
488                };
489                // Optional `LANGUAGE <name>` trailer (idents only).
490                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
491                    self.advance();
492                    let _ = self.expect_ident_like()?;
493                }
494                // Parse the body — same shape CREATE FUNCTION
495                // uses for trigger function bodies. If the body
496                // doesn't parse cleanly we surface the error
497                // (better than silent no-op).
498                let block = parse_plpgsql_body(&body_text)?;
499                Ok(Statement::DoBlock(block))
500            }
501            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
502            // WITH isn't a reserved token in our lexer — comes through
503            // as `Token::Ident("with")` (case-insensitive).
504            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
505                self.advance();
506                self.parse_with_cte_then_select()
507            }
508            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
509            // an identifier — not a reserved keyword.
510            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
511                self.advance();
512                let mut analyze = false;
513                let mut suggest = false;
514                // v6.8.3 — `EXPLAIN (SUGGEST)` opt-in.
515                if matches!(self.peek(), Token::LParen) {
516                    self.advance();
517                    let opt = match self.peek().clone() {
518                        Token::Ident(s) | Token::QuotedIdent(s) => s,
519                        other => {
520                            return Err(self.err(format!(
521                                "expected option keyword inside EXPLAIN (…), got {other:?}"
522                            )));
523                        }
524                    };
525                    if !opt.eq_ignore_ascii_case("suggest") {
526                        return Err(self.err(format!(
527                            "unknown EXPLAIN option {opt:?}; v6.8.3 supports SUGGEST"
528                        )));
529                    }
530                    self.advance();
531                    if !matches!(self.peek(), Token::RParen) {
532                        return Err(self.err(format!(
533                            "expected ')' after EXPLAIN option, got {:?}",
534                            self.peek()
535                        )));
536                    }
537                    self.advance();
538                    suggest = true;
539                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
540                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
541                {
542                    self.advance();
543                    analyze = true;
544                }
545                let inner = self.parse_select_stmt()?;
546                let Statement::Select(s) = inner else {
547                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
548                };
549                Ok(Statement::Explain(crate::ast::ExplainStatement {
550                    analyze,
551                    inner: Box::new(s),
552                    suggest,
553                }))
554            }
555            Token::Create => self.parse_create_stmt(),
556            Token::Insert => self.parse_insert_stmt(),
557            Token::Begin => {
558                self.advance();
559                Ok(Statement::Begin)
560            }
561            Token::Commit => {
562                self.advance();
563                Ok(Statement::Commit)
564            }
565            Token::Rollback => {
566                self.advance();
567                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
568                // savepoint without ending the transaction. Bare
569                // `ROLLBACK` drops the whole TX.
570                if matches!(self.peek(), Token::To) {
571                    self.advance();
572                    if matches!(self.peek(), Token::Savepoint) {
573                        self.advance();
574                    }
575                    let name = self.expect_ident_like()?;
576                    Ok(Statement::RollbackToSavepoint(name))
577                } else {
578                    Ok(Statement::Rollback)
579                }
580            }
581            Token::Savepoint => {
582                self.advance();
583                let name = self.expect_ident_like()?;
584                Ok(Statement::Savepoint(name))
585            }
586            Token::Release => {
587                self.advance();
588                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
589                // is optional in standard SQL.
590                if matches!(self.peek(), Token::Savepoint) {
591                    self.advance();
592                }
593                let name = self.expect_ident_like()?;
594                Ok(Statement::ReleaseSavepoint(name))
595            }
596            Token::Show => {
597                self.advance();
598                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
599                // v6.1.2 promoted TABLES to a reserved keyword (for
600                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
601                // arrives as `Token::Tables` rather than a bare ident.
602                // USERS / COLUMNS remain bare idents.
603                let target = match self.advance() {
604                    Token::Tables => "tables".to_string(),
605                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
606                    // keyword token; recognise it as the SHOW CREATE
607                    // dispatch keyword too.
608                    Token::Create => "create".to_string(),
609                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
610                    // keyword too; let SHOW INDEX FROM parse.
611                    Token::Index => "index".to_string(),
612                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
613                    other => {
614                        return Err(self.err(format!(
615                            "expected SHOW target, got {other:?}"
616                        )));
617                    }
618                };
619                match target.as_str() {
620                    "tables" => Ok(Statement::ShowTables),
621                    "users" => Ok(Statement::ShowUsers),
622                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
623                    // TABLE <t>` returns a 2-column row: (Table,
624                    // Create Table). mysqldump emits this for every
625                    // table at scrape time; without it the dump
626                    // round-trip stalls.
627                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
628                    // FROM <t>` (also spelled `SHOW INDEX` and
629                    // `SHOW KEYS`). admin / mysqldump probes use
630                    // it to list per-table indexes.
631                    "indexes" | "index" | "keys" => {
632                        if !matches!(self.peek(), Token::From) {
633                            return Err(self.err(format!(
634                                "expected FROM after SHOW INDEXES, got {:?}",
635                                self.peek()
636                            )));
637                        }
638                        self.advance();
639                        let table = self.expect_ident_like()?;
640                        Ok(Statement::ShowIndexes(table))
641                    }
642                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
643                    // `SHOW VARIABLES`. Both return a 2-column row
644                    // set listing server-side state; clients probe
645                    // them at connect time.
646                    "status" => Ok(Statement::ShowStatus),
647                    "variables" => Ok(Statement::ShowVariables),
648                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
649                    "processlist" => Ok(Statement::ShowProcesslist),
650                    "create" => {
651                        // SHOW CREATE TABLE / VIEW / DATABASE — only
652                        // TABLE is supported in v7.17.
653                        let kind = match self.advance() {
654                            Token::Ident(s) | Token::QuotedIdent(s) => s,
655                            Token::Table => "table".to_string(),
656                            other => {
657                                return Err(self.err(format!(
658                                    "expected TABLE after SHOW CREATE, got {other:?}"
659                                )));
660                            }
661                        };
662                        if !kind.eq_ignore_ascii_case("table") {
663                            return Err(self.err(format!(
664                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
665                            )));
666                        }
667                        let name = self.expect_ident_like()?;
668                        Ok(Statement::ShowCreateTable(name))
669                    }
670                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
671                    // (and `SHOW SCHEMAS` alias). The mysql client uses
672                    // it to populate the database selector at connect
673                    // time; without it `mysql -p` errors before the
674                    // first user query.
675                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
676                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
677                    // keyword on its own; it lands here as a bare
678                    // ident. Returning all publications + their
679                    // scope summary.
680                    "publications" => Ok(Statement::ShowPublications),
681                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
682                    "subscriptions" => Ok(Statement::ShowSubscriptions),
683                    "columns" => {
684                        if !matches!(self.peek(), Token::From) {
685                            return Err(self.err(format!(
686                                "expected FROM after SHOW COLUMNS, got {:?}",
687                                self.peek()
688                            )));
689                        }
690                        self.advance();
691                        let table = self.expect_ident_like()?;
692                        Ok(Statement::ShowColumns(table))
693                    }
694                    other => Err(self.err(format!(
695                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
696                    ))),
697                }
698            }
699            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
700            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
701            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
702            // arrived as a bare ident; tokenising it dedicatedly
703            // keeps the dispatch tree small.
704            Token::Drop => {
705                self.advance();
706                match self.peek() {
707                    Token::Publication => {
708                        self.advance();
709                        let name = self.expect_ident_or_string()?;
710                        Ok(Statement::DropPublication(name))
711                    }
712                    Token::Subscription => {
713                        self.advance();
714                        let name = self.expect_ident_or_string()?;
715                        Ok(Statement::DropSubscription(name))
716                    }
717                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
718                        self.advance();
719                        let name = self.expect_ident_or_string()?;
720                        Ok(Statement::DropUser(name))
721                    }
722                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
723                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
724                        self.advance();
725                        let if_exists = self.consume_if_exists();
726                        let name = self.expect_ident_like()?;
727                        // ON <table>
728                        if !matches!(self.peek(), Token::On) {
729                            return Err(self.err(alloc::format!(
730                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
731                                self.peek()
732                            )));
733                        }
734                        self.advance();
735                        let table = self.expect_ident_like()?;
736                        Ok(Statement::DropTrigger {
737                            name,
738                            table,
739                            if_exists,
740                        })
741                    }
742                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
743                    // v7.12.4 ignores any optional arg-list (signature-
744                    // based overload disambiguation lands in v7.12.5+).
745                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
746                        self.advance();
747                        let if_exists = self.consume_if_exists();
748                        let name = self.expect_ident_like()?;
749                        // Optional `()` — consume + discard.
750                        if matches!(self.peek(), Token::LParen) {
751                            self.advance();
752                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
753                            let mut depth = 1usize;
754                            while depth > 0 {
755                                match self.peek() {
756                                    Token::LParen => depth += 1,
757                                    Token::RParen => depth -= 1,
758                                    Token::Eof => {
759                                        return Err(self.err(alloc::format!(
760                                            "unterminated arg list in DROP FUNCTION {name:?}"
761                                        )));
762                                    }
763                                    _ => {}
764                                }
765                                self.advance();
766                            }
767                        }
768                        Ok(Statement::DropFunction { name, if_exists })
769                    }
770                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
771                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
772                    // emit DROP TABLE IF EXISTS at the head of every
773                    // CREATE TABLE block so re-importing a dump
774                    // overwrites prior state. SPG accepts and removes
775                    // matching tables; CASCADE/RESTRICT trailers
776                    // accepted silently.
777                    Token::Table => {
778                        self.advance();
779                        let if_exists = self.consume_if_exists();
780                        let mut names: Vec<String> = Vec::new();
781                        loop {
782                            names.push(self.expect_ident_like()?);
783                            if matches!(self.peek(), Token::Comma) {
784                                self.advance();
785                                continue;
786                            }
787                            break;
788                        }
789                        if matches!(
790                            self.peek(),
791                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
792                                || s.eq_ignore_ascii_case("restrict")
793                        ) {
794                            self.advance();
795                        }
796                        Ok(Statement::DropTable { names, if_exists })
797                    }
798                    // v7.14.0 — DROP INDEX [IF EXISTS] name
799                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
800                    // for partial-index renames and pgvector
801                    // migrations. SPG removes the matching index;
802                    // IF EXISTS makes the drop idempotent.
803                    Token::Index => {
804                        self.advance();
805                        let if_exists = self.consume_if_exists();
806                        let name = self.expect_ident_like()?;
807                        if matches!(
808                            self.peek(),
809                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
810                                || s.eq_ignore_ascii_case("restrict")
811                        ) {
812                            self.advance();
813                        }
814                        Ok(Statement::DropIndex { name, if_exists })
815                    }
816                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
817                    // [CASCADE|RESTRICT]. SPG is single-database;
818                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
819                    // name [, name…] [CASCADE | RESTRICT]. Real
820                    // unregister (was silent no-op pre-v7.17).
821                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
822                        self.advance();
823                        let if_exists = self.consume_if_exists();
824                        let mut names = vec![self.expect_ident_like()?];
825                        while matches!(self.peek(), Token::Comma) {
826                            self.advance();
827                            names.push(self.expect_ident_like()?);
828                        }
829                        if matches!(
830                            self.peek(),
831                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
832                                || s.eq_ignore_ascii_case("restrict")
833                        ) {
834                            self.advance();
835                        }
836                        Ok(Statement::DropSchema { names, if_exists })
837                    }
838                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
839                    // name [, name…] [CASCADE|RESTRICT].
840                    Token::Ident(s) | Token::QuotedIdent(s)
841                        if s.eq_ignore_ascii_case("type") =>
842                    {
843                        self.advance();
844                        let if_exists = self.consume_if_exists();
845                        let mut names = vec![self.expect_ident_like()?];
846                        while matches!(self.peek(), Token::Comma) {
847                            self.advance();
848                            names.push(self.expect_ident_like()?);
849                        }
850                        if matches!(
851                            self.peek(),
852                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
853                                || s.eq_ignore_ascii_case("restrict")
854                        ) {
855                            self.advance();
856                        }
857                        Ok(Statement::DropType { names, if_exists })
858                    }
859                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
860                    // name [, name…] [CASCADE|RESTRICT].
861                    Token::Ident(s) | Token::QuotedIdent(s)
862                        if s.eq_ignore_ascii_case("domain") =>
863                    {
864                        self.advance();
865                        let if_exists = self.consume_if_exists();
866                        let mut names = vec![self.expect_ident_like()?];
867                        while matches!(self.peek(), Token::Comma) {
868                            self.advance();
869                            names.push(self.expect_ident_like()?);
870                        }
871                        if matches!(
872                            self.peek(),
873                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
874                                || s.eq_ignore_ascii_case("restrict")
875                        ) {
876                            self.advance();
877                        }
878                        Ok(Statement::DropDomain { names, if_exists })
879                    }
880                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
881                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
882                    Token::Ident(s) | Token::QuotedIdent(s)
883                        if s.eq_ignore_ascii_case("materialized") =>
884                    {
885                        self.advance();
886                        let nxt = self.peek().clone();
887                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
888                        {
889                            return Err(self.err(alloc::format!(
890                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
891                            )));
892                        }
893                        self.advance();
894                        let if_exists = self.consume_if_exists();
895                        let mut names = vec![self.expect_ident_like()?];
896                        while matches!(self.peek(), Token::Comma) {
897                            self.advance();
898                            names.push(self.expect_ident_like()?);
899                        }
900                        if matches!(
901                            self.peek(),
902                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
903                                || s.eq_ignore_ascii_case("restrict")
904                        ) {
905                            self.advance();
906                        }
907                        Ok(Statement::DropMaterializedView { names, if_exists })
908                    }
909                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
910                    // name [, name…] [CASCADE|RESTRICT].
911                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
912                        self.advance();
913                        let if_exists = self.consume_if_exists();
914                        let mut names = vec![self.expect_ident_like()?];
915                        while matches!(self.peek(), Token::Comma) {
916                            self.advance();
917                            names.push(self.expect_ident_like()?);
918                        }
919                        if matches!(
920                            self.peek(),
921                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
922                                || s.eq_ignore_ascii_case("restrict")
923                        ) {
924                            self.advance();
925                        }
926                        Ok(Statement::DropView { names, if_exists })
927                    }
928                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
929                    // [CASCADE|RESTRICT]. Real removal from catalog
930                    // (was a silent no-op pre-v7.17).
931                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
932                        self.advance();
933                        let if_exists = self.consume_if_exists();
934                        let mut names = vec![self.expect_ident_like()?];
935                        while matches!(self.peek(), Token::Comma) {
936                            self.advance();
937                            names.push(self.expect_ident_like()?);
938                        }
939                        if matches!(
940                            self.peek(),
941                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
942                                || s.eq_ignore_ascii_case("restrict")
943                        ) {
944                            self.advance();
945                        }
946                        Ok(Statement::DropSequence { names, if_exists })
947                    }
948                    other => Err(self.err(format!(
949                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
950                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
951                    ))),
952                }
953            }
954            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
955            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
956                self.advance();
957                let nxt = self.peek().clone();
958                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
959                {
960                    return Err(self.err(alloc::format!(
961                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
962                    )));
963                }
964                self.advance();
965                let nxt2 = self.peek().clone();
966                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
967                {
968                    return Err(self.err(alloc::format!(
969                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
970                    )));
971                }
972                self.advance();
973                let name = self.expect_ident_like()?;
974                let with_data = self.parse_optional_with_data(true)?;
975                Ok(Statement::RefreshMaterializedView { name, with_data })
976            }
977            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
978                self.advance();
979                self.parse_update_after_keyword()
980            }
981            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
982                self.advance();
983                self.parse_delete_after_keyword()
984            }
985            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
986            // ALTER is not a reserved keyword in the lexer — handled
987            // as a bare ident here.
988            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
989                self.advance();
990                self.parse_alter_after_keyword()
991            }
992            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
993            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
994            // additions needed.
995            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
996                self.advance();
997                self.parse_wait_after_keyword()
998            }
999            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
1000            // Bare ANALYZE → analyse every user table; ANALYZE
1001            // <name> → re-stats one. The argument is an optional
1002            // ident (or quoted ident); anything else is a parse
1003            // error.
1004            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
1005            // `WHERE` filter (carved out per V6_7_DESIGN.md
1006            // STABILITY). Lex order: identifier "compact" → "cold"
1007            // → "segments". Anything else after `COMPACT` is a
1008            // parse error.
1009            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
1010                self.advance();
1011                let next = self.peek().clone();
1012                let cold = match next {
1013                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1014                    _ => {
1015                        return Err(
1016                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
1017                        );
1018                    }
1019                };
1020                if !cold.eq_ignore_ascii_case("cold") {
1021                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
1022                }
1023                self.advance();
1024                let next = self.peek().clone();
1025                let segments = match next {
1026                    Token::Ident(s) | Token::QuotedIdent(s) => s,
1027                    _ => {
1028                        return Err(self.err(format!(
1029                            "expected SEGMENTS after COMPACT COLD, got {:?}",
1030                            self.peek()
1031                        )));
1032                    }
1033                };
1034                if !segments.eq_ignore_ascii_case("segments") {
1035                    return Err(self.err(format!(
1036                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
1037                    )));
1038                }
1039                self.advance();
1040                Ok(Statement::CompactColdSegments)
1041            }
1042            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
1043            // Parsed as a case-insensitive identifier since MERGE
1044            // isn't a reserved lexer keyword (collides with the
1045            // mysqldump `ALGORITHM = MERGE` view clause if it
1046            // were); the inner parser drives the rest of the
1047            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
1048            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
1049                self.advance();
1050                self.parse_merge_after_keyword()
1051            }
1052            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
1053                self.advance();
1054                let target = match self.peek() {
1055                    Token::Eof | Token::Semicolon => None,
1056                    Token::Ident(_) | Token::QuotedIdent(_) => {
1057                        Some(self.expect_ident_like()?)
1058                    }
1059                    other => {
1060                        return Err(self.err(format!(
1061                            "expected table name or end of statement after ANALYZE, got {other:?}"
1062                        )));
1063                    }
1064                };
1065                Ok(Statement::Analyze(target))
1066            }
1067            // v7.12.1 — `SET <name> [TO|=] <value>`. The
1068            // `default_text_search_config` parameter is consumed
1069            // by the FTS function dispatcher; other parameter
1070            // names are recorded but treated as a no-op so PG
1071            // dump output loads.
1072            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
1073                self.advance();
1074                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
1075                // — accept and ignore. MySQL adds `SET GLOBAL` too
1076                // (and the alias `SET @@global.name = …` which the
1077                // SessionVar path handles).
1078                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"))
1079                {
1080                    self.advance();
1081                }
1082                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
1083                // <collation>]` — change the connection client
1084                // charset. SPG stores UTF-8 always and orders
1085                // bytewise; accept as a no-op.
1086                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
1087                {
1088                    self.advance();
1089                    // Charset ident-or-string.
1090                    if matches!(
1091                        self.peek(),
1092                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1093                    ) {
1094                        self.advance();
1095                    }
1096                    // Optional `COLLATE <name>`.
1097                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
1098                    {
1099                        self.advance();
1100                        if matches!(
1101                            self.peek(),
1102                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1103                        ) {
1104                            self.advance();
1105                        }
1106                    }
1107                    return Ok(Statement::Empty);
1108                }
1109                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
1110                // { DEFAULT | '<role>' | <ident> }` (mailrs
1111                // round-10 A.1). pg_dump preamble emits the
1112                // `DEFAULT` form to reset session authorization;
1113                // SPG has no role system so this is a strict
1114                // no-op. PG also accepts `RESET SESSION
1115                // AUTHORIZATION` (handled by the RESET parser
1116                // elsewhere). Reference:
1117                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
1118                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
1119                {
1120                    self.advance(); // AUTHORIZATION
1121                    match self.peek().clone() {
1122                        Token::Default => {
1123                            self.advance();
1124                        }
1125                        Token::String(_)
1126                        | Token::Ident(_)
1127                        | Token::QuotedIdent(_) => {
1128                            self.advance();
1129                        }
1130                        other => {
1131                            return Err(self.err(alloc::format!(
1132                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
1133                            )));
1134                        }
1135                    }
1136                    return Ok(Statement::Empty);
1137                }
1138                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
1139                // alias — same accept-as-no-op as SET NAMES.
1140                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
1141                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
1142                {
1143                    self.advance(); // CHARACTER
1144                    self.advance(); // SET
1145                    if matches!(
1146                        self.peek(),
1147                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1148                    ) {
1149                        self.advance();
1150                    }
1151                    return Ok(Statement::Empty);
1152                }
1153                // v7.14.0 — multi-assignment form
1154                // `SET a = 1, b = 2, …`. Single-assignment is the
1155                // 1-element case. Each LHS may be a regular ident
1156                // or a SessionVar (`@VAR` / `@@VAR`).
1157                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
1158                loop {
1159                    let lhs = match self.peek().clone() {
1160                        Token::SessionVar(s) => {
1161                            self.advance();
1162                            s
1163                        }
1164                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
1165                        other => {
1166                            return Err(self.err(format!(
1167                                "expected parameter name after SET, got {other:?}"
1168                            )));
1169                        }
1170                    };
1171                    // Accept either `=` or the bare `TO` keyword.
1172                    match self.peek() {
1173                        Token::Eq => {
1174                            self.advance();
1175                        }
1176                        Token::To => {
1177                            self.advance();
1178                        }
1179                        other => {
1180                            return Err(self.err(format!(
1181                                "expected `=` or TO after SET {lhs}, got {other:?}"
1182                            )));
1183                        }
1184                    }
1185                    let value = self.parse_set_value()?;
1186                    pairs.push((lhs, value));
1187                    if matches!(self.peek(), Token::Comma) {
1188                        self.advance();
1189                        continue;
1190                    }
1191                    break;
1192                }
1193                if pairs.len() == 1 {
1194                    let (name, value) = pairs.into_iter().next().unwrap();
1195                    Ok(Statement::SetParameter { name, value })
1196                } else {
1197                    Ok(Statement::SetParameterList(pairs))
1198                }
1199            }
1200            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1201            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1202                self.advance();
1203                match self.peek().clone() {
1204                    Token::All => {
1205                        self.advance();
1206                        Ok(Statement::ResetParameter(None))
1207                    }
1208                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1209                        self.advance();
1210                        Ok(Statement::ResetParameter(None))
1211                    }
1212                    _ => {
1213                        let name = self.parse_set_param_name()?;
1214                        Ok(Statement::ResetParameter(Some(name)))
1215                    }
1216                }
1217            }
1218            other => Err(self.err(format!(
1219                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1220                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1221            ))),
1222        }
1223    }
1224
1225    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1226        debug_assert!(matches!(self.peek(), Token::Create));
1227        self.advance();
1228        match self.peek() {
1229            Token::Table => self.parse_create_table_stmt_after_create(),
1230            Token::Index => self.parse_create_index_stmt_after_create(false),
1231            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1232            // The `UNIQUE` modifier turns a partial index into a
1233            // partial-uniqueness invariant (only rows matching the
1234            // WHERE predicate are checked for duplicates). mailrs
1235            // K1 (3 hits: email_templates default, calendar_events
1236            // master, calendar_events instance).
1237            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1238                self.advance();
1239                if !matches!(self.peek(), Token::Index) {
1240                    return Err(self.err(alloc::format!(
1241                        "expected INDEX after CREATE UNIQUE, got {:?}",
1242                        self.peek()
1243                    )));
1244                }
1245                self.parse_create_index_stmt_after_create(true)
1246            }
1247            Token::Publication => {
1248                self.advance();
1249                self.parse_create_publication_after_keyword()
1250            }
1251            Token::Subscription => {
1252                self.advance();
1253                self.parse_create_subscription_after_keyword()
1254            }
1255            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1256            // USER isn't a reserved keyword — we look for the bare
1257            // identifier so the lexer doesn't have to grow a token.
1258            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1259                self.advance();
1260                self.parse_create_user_after_keyword()
1261            }
1262            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1263            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1264            // no-op. mailrs follow-up F3.
1265            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1266                self.advance();
1267                self.parse_create_extension_after_keyword()
1268            }
1269            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1270            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1271            // optional; absorb it here and forward to the
1272            // per-kind parsers with the flag. OR is a reserved
1273            // keyword token.
1274            Token::Or => {
1275                self.advance();
1276                let next = self.peek();
1277                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1278                    return Err(self.err(alloc::format!(
1279                        "expected REPLACE after CREATE OR, got {next:?}"
1280                    )));
1281                };
1282                if !s2.eq_ignore_ascii_case("replace") {
1283                    return Err(self.err(alloc::format!(
1284                        "expected REPLACE after CREATE OR, got {s2:?}"
1285                    )));
1286                }
1287                self.advance();
1288                self.parse_create_function_or_trigger_after_or_replace(true)
1289            }
1290            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1291                self.advance();
1292                self.parse_create_function_after_keyword(false)
1293            }
1294            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1295                self.advance();
1296                self.parse_create_trigger_after_keyword(false)
1297            }
1298            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1299            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1300                self.advance();
1301                self.parse_create_sequence_after_keyword(false)
1302            }
1303            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1304            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1305                self.advance();
1306                self.parse_create_view_after_keyword(false, false, false)
1307            }
1308            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1309            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1310            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1311            // appear (in any order) between `CREATE` and `VIEW` in
1312            // every mysqldump-emitted view. Pre-2.6 the parser
1313            // rejected the prefix and the customer's whole view
1314            // backup failed on the first view. The hints are pure
1315            // planner / permission metadata; SPG's view-rewrite
1316            // path is semantically equivalent for all three
1317            // algorithms in v7.17 (TEMPTABLE differs only in
1318            // perf for huge views — out of v7.17 scope), and
1319            // DEFINER / SQL SECURITY are pure single-user
1320            // permissioning that SPG ignores by design.
1321            Token::Ident(s) | Token::QuotedIdent(s)
1322                if s.eq_ignore_ascii_case("algorithm")
1323                    || s.eq_ignore_ascii_case("definer")
1324                    || s.eq_ignore_ascii_case("sql") =>
1325            {
1326                self.consume_mysql_view_prefix()?;
1327                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1328                // (in any order, in any combination), the next
1329                // keyword must be VIEW. mysqldump never emits these
1330                // prefixes on non-view statements.
1331                let next = self.peek().clone();
1332                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1333                    if s2.eq_ignore_ascii_case("view"))
1334                {
1335                    self.advance();
1336                    self.parse_create_view_after_keyword(false, false, false)
1337                } else {
1338                    Err(self.err(alloc::format!(
1339                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1340                    )))
1341                }
1342            }
1343            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1344            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1345                self.advance();
1346                self.parse_create_type_after_keyword()
1347            }
1348            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1349            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1350            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1351                self.advance();
1352                self.parse_create_domain_after_keyword()
1353            }
1354            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1355            // name [AUTHORIZATION user]. Real catalog registry
1356            // (was silent-no-op'd pre-v7.17).
1357            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1358                self.advance();
1359                let if_not_exists = self.parse_if_not_exists();
1360                let name = self.expect_ident_like()?;
1361                // Optional `AUTHORIZATION <user>` trailer — accepted,
1362                // ignored (single-user catalog).
1363                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1364                    if s.eq_ignore_ascii_case("authorization"))
1365                {
1366                    self.advance();
1367                    let _ = self.expect_ident_like()?;
1368                }
1369                Ok(Statement::CreateSchema { name, if_not_exists })
1370            }
1371            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1372            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1373                self.advance();
1374                let next = self.peek().clone();
1375                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1376                {
1377                    self.advance();
1378                    self.parse_create_materialized_view_after_keyword()
1379                } else {
1380                    Err(self.err(alloc::format!(
1381                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1382                    )))
1383                }
1384            }
1385            Token::Ident(s) | Token::QuotedIdent(s)
1386                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1387            {
1388                self.advance();
1389                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1390                let next = self.peek().clone();
1391                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1392                {
1393                    self.advance();
1394                    self.parse_create_sequence_after_keyword(true)
1395                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1396                {
1397                    self.advance();
1398                    self.parse_create_view_after_keyword(false, false, true)
1399                } else {
1400                    // TEMP TABLE etc — consume to boundary as noop for now.
1401                    self.consume_until_statement_boundary();
1402                    Ok(Statement::Empty)
1403                }
1404            }
1405            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1406            // BEGIN <body> END`. The body may reference `@var`
1407            // session variables, SET statements, internal `;`
1408            // terminators, etc. SPG has no procedure runtime, so
1409            // consume the whole `CREATE PROCEDURE … END` block as
1410            // a no-op so mysqldump scripts that include stored
1411            // routines load through. The matching-END consumer
1412            // tracks BEGIN/END nesting depth to handle nested
1413            // BEGIN blocks correctly.
1414            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1415                self.consume_mysql_routine_body();
1416                Ok(Statement::Empty)
1417            }
1418            // v7.14.0 — pg_dump / mysqldump emit
1419            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1420            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1421            // SPG is single-schema / single-database; these have
1422            // no behavioural effect, so consume + return Empty.
1423            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1424            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1425            // moved up to real parser branches. DATABASE / ROLE /
1426            // POLICY / OPERATOR stay no-op forever
1427            // (single-database, hardcoded roles).
1428            Token::Ident(s) | Token::QuotedIdent(s)
1429                if matches!(
1430                    s.to_ascii_lowercase().as_str(),
1431                    "database"
1432                        | "role"
1433                        | "policy"
1434                        | "operator"
1435                        | "cast"
1436                        | "rule"
1437                        | "aggregate"
1438                        | "language"
1439                        | "collation"
1440                        | "conversion"
1441                        // v7.17.0 Phase 8 (audit N6) — rarely-
1442                        // emitted pg_dump shapes that should
1443                        // load through without a parser error.
1444                        // SPG has no planner statistics catalog,
1445                        // no event-trigger hooks, no foreign-
1446                        // data-wrapper infrastructure; consume
1447                        // + return Empty.
1448                        | "statistics"
1449                        | "event"
1450                        | "foreign"
1451                ) =>
1452            {
1453                self.consume_until_statement_boundary();
1454                Ok(Statement::Empty)
1455            }
1456            other => Err(self.err(format!(
1457                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1458            ))),
1459        }
1460    }
1461
1462    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1463    /// keyword decides whether we parse a function or trigger
1464    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1465    /// PROCEDURE) — those land in later releases.
1466    fn parse_create_function_or_trigger_after_or_replace(
1467        &mut self,
1468        or_replace: bool,
1469    ) -> Result<Statement, ParseError> {
1470        let tok = self.peek();
1471        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1472            return Err(self.err(alloc::format!(
1473                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1474            )));
1475        };
1476        if s.eq_ignore_ascii_case("function") {
1477            self.advance();
1478            self.parse_create_function_after_keyword(or_replace)
1479        } else if s.eq_ignore_ascii_case("trigger") {
1480            self.advance();
1481            self.parse_create_trigger_after_keyword(or_replace)
1482        } else if s.eq_ignore_ascii_case("view") {
1483            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1484            self.advance();
1485            self.parse_create_view_after_keyword(or_replace, false, false)
1486        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1487            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1488            self.advance();
1489            let nxt = self.peek().clone();
1490            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1491            {
1492                self.advance();
1493                self.parse_create_view_after_keyword(or_replace, false, true)
1494            } else {
1495                Err(self.err(alloc::format!(
1496                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1497                )))
1498            }
1499        } else {
1500            Err(self.err(alloc::format!(
1501                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1502            )))
1503        }
1504    }
1505
1506    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1507    /// SPG doesn't have a registry; pgvector / similar are
1508    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1509    /// the syntax lets dual-target schemas keep the line.
1510    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1511        // Optional `IF NOT EXISTS`.
1512        self.consume_if_not_exists();
1513        let name = self.expect_ident_like()?;
1514        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1515        // CASCADE / FROM '<v>' clauses; we don't model them.
1516        loop {
1517            match self.peek() {
1518                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1519                    self.advance();
1520                    continue;
1521                }
1522                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1523                    self.advance();
1524                    let _ = self.expect_ident_like()?;
1525                    continue;
1526                }
1527                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1528                    self.advance();
1529                    // String or ident literal.
1530                    let _ = self.advance();
1531                    continue;
1532                }
1533                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1534                    self.advance();
1535                    let _ = self.advance();
1536                    continue;
1537                }
1538                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1539                    self.advance();
1540                    continue;
1541                }
1542                _ => break,
1543            }
1544        }
1545        Ok(Statement::CreateExtension(name))
1546    }
1547
1548    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1549    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1550    /// already been consumed by the caller. Grammar accepted:
1551    ///
1552    ///   name `(` arg-list `)`
1553    ///   `RETURNS` return-type
1554    ///   [ `LANGUAGE` ident ]
1555    ///   `AS` $$ body $$
1556    ///   [ `LANGUAGE` ident ]
1557    ///
1558    /// Either `LANGUAGE` position is allowed; PG accepts both.
1559    fn parse_create_function_after_keyword(
1560        &mut self,
1561        or_replace: bool,
1562    ) -> Result<Statement, ParseError> {
1563        let name = self.expect_ident_like()?;
1564        // Argument list. v7.12.4 commonly sees the empty `()`
1565        // (trigger functions); typed args parse and round-trip
1566        // but the executor only invokes nullary functions.
1567        if !matches!(self.peek(), Token::LParen) {
1568            return Err(self.err(alloc::format!(
1569                "expected '(' after function name {name:?}, got {:?}",
1570                self.peek()
1571            )));
1572        }
1573        self.advance();
1574        let args = self.parse_function_arg_list()?;
1575        // RETURNS clause.
1576        let tok = self.peek();
1577        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1578            return Err(self.err(alloc::format!(
1579                "expected RETURNS after function arg list, got {tok:?}"
1580            )));
1581        };
1582        if !s.eq_ignore_ascii_case("returns") {
1583            return Err(self.err(alloc::format!(
1584                "expected RETURNS after function arg list, got {s:?}"
1585            )));
1586        }
1587        self.advance();
1588        let returns = self.parse_function_return()?;
1589        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1590        // re-check after the body too).
1591        let mut language: Option<String> = self.parse_optional_language()?;
1592        // `AS` followed by a $$-quoted body (lexer already
1593        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1594        // Token::String). AS is a reserved keyword (Token::As).
1595        if !matches!(self.peek(), Token::As) {
1596            return Err(self.err(alloc::format!(
1597                "expected AS before function body, got {:?}",
1598                self.peek()
1599            )));
1600        }
1601        self.advance();
1602        let body_text = match self.peek() {
1603            Token::String(s) => {
1604                let body = s.clone();
1605                self.advance();
1606                body
1607            }
1608            other => {
1609                return Err(self.err(alloc::format!(
1610                    "expected $$-quoted function body after AS, got {other:?}"
1611                )));
1612            }
1613        };
1614        // Trailing optional LANGUAGE clause (the other PG position).
1615        if language.is_none() {
1616            language = self.parse_optional_language()?;
1617        }
1618        let language = language.unwrap_or_else(|| String::from("sql"));
1619        // PL/pgSQL bodies get structure-parsed. Other languages
1620        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1621        // recognise) round-trip as Raw text — the executor errors
1622        // when invoked with a clear unsupported message.
1623        let body = if language.eq_ignore_ascii_case("plpgsql") {
1624            match parse_plpgsql_body(&body_text) {
1625                Ok(block) => FunctionBody::PlPgSql(block),
1626                // Best-effort: if the body parser doesn't yet
1627                // support a construct used inside, fall back to
1628                // raw — keeps `CREATE FUNCTION` itself working
1629                // (catalogue accepts), executor errors on
1630                // invocation only.
1631                Err(_) => FunctionBody::Raw(body_text),
1632            }
1633        } else {
1634            FunctionBody::Raw(body_text)
1635        };
1636        Ok(Statement::CreateFunction(CreateFunctionStatement {
1637            name,
1638            or_replace,
1639            args,
1640            returns,
1641            language,
1642            body,
1643        }))
1644    }
1645
1646    /// Closing `)`-terminated argument list. v7.12.4 commonly
1647    /// sees the empty `()`; typed args round-trip but the
1648    /// executor (yet) doesn't invoke them.
1649    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1650        let mut args: Vec<FunctionArg> = Vec::new();
1651        if matches!(self.peek(), Token::RParen) {
1652            self.advance();
1653            return Ok(args);
1654        }
1655        loop {
1656            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1657            // a reserved token; OUT / INOUT are bare idents.
1658            let mode = if matches!(self.peek(), Token::In) {
1659                self.advance();
1660                FunctionArgMode::In
1661            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1662            {
1663                self.advance();
1664                FunctionArgMode::Out
1665            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1666            {
1667                self.advance();
1668                FunctionArgMode::InOut
1669            } else {
1670                FunctionArgMode::In
1671            };
1672            // Optional name. The next token is either a name
1673            // (followed by a type ident) or the type itself.
1674            // Disambiguate by peeking ahead: if the token after
1675            // the next ident is also an ident, we treat the
1676            // first as the name.
1677            let (name, ty_token) = {
1678                let first = self.expect_ident_like()?;
1679                // Peek next: if it's an ident (i.e. a type
1680                // name) the `first` was the arg name.
1681                match self.peek() {
1682                    Token::Ident(_) | Token::QuotedIdent(_) => {
1683                        let ty = self.expect_ident_like()?;
1684                        (Some(first), ty)
1685                    }
1686                    _ => (None, first),
1687                }
1688            };
1689            // Type — try to map to ColumnTypeName, else Raw.
1690            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1691                Some(t) => FunctionArgType::Typed(t),
1692                None => FunctionArgType::Raw(ty_token),
1693            };
1694            args.push(FunctionArg { mode, name, ty });
1695            match self.peek() {
1696                Token::Comma => {
1697                    self.advance();
1698                    continue;
1699                }
1700                Token::RParen => {
1701                    self.advance();
1702                    return Ok(args);
1703                }
1704                other => {
1705                    return Err(self.err(alloc::format!(
1706                        "expected , or ) in function arg list, got {other:?}"
1707                    )));
1708                }
1709            }
1710        }
1711    }
1712
1713    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1714        let ident = self.expect_ident_like()?;
1715        if ident.eq_ignore_ascii_case("trigger") {
1716            return Ok(FunctionReturn::Trigger);
1717        }
1718        if ident.eq_ignore_ascii_case("void") {
1719            return Ok(FunctionReturn::Void);
1720        }
1721        match map_type_ident_to_column_type_name(&ident) {
1722            Some(t) => Ok(FunctionReturn::Type(t)),
1723            None => Ok(FunctionReturn::Other(ident)),
1724        }
1725    }
1726
1727    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1728        match self.peek() {
1729            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1730                self.advance();
1731                let lang = self.expect_ident_like()?;
1732                Ok(Some(lang.to_ascii_lowercase()))
1733            }
1734            _ => Ok(None),
1735        }
1736    }
1737
1738    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1739    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1740    /// (expr)]*`. The `DOMAIN` keyword has already been
1741    /// consumed. PG allows the trailing constraints in any
1742    /// order; we approximate with a small loop.
1743    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1744        let name = self.expect_ident_like()?;
1745        // Optional `AS`.
1746        if matches!(self.peek(), Token::As) {
1747            self.advance();
1748        }
1749        let base_type = self.parse_column_type_name()?;
1750        let mut default: Option<Expr> = None;
1751        let mut not_null = false;
1752        let mut checks: Vec<Expr> = Vec::new();
1753        loop {
1754            match self.peek() {
1755                Token::Default => {
1756                    if default.is_some() {
1757                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1758                    }
1759                    self.advance();
1760                    default = Some(self.parse_expr(0)?);
1761                }
1762                Token::Not => {
1763                    self.advance();
1764                    if !matches!(self.peek(), Token::Null) {
1765                        return Err(self.err(alloc::format!(
1766                            "expected NULL after NOT in DOMAIN, got {:?}",
1767                            self.peek()
1768                        )));
1769                    }
1770                    self.advance();
1771                    not_null = true;
1772                }
1773                Token::Null => {
1774                    self.advance();
1775                    // NULL after a NOT NULL is contradictory, but
1776                    // PG accepts bare NULL as the default-nullable
1777                    // marker. No-op.
1778                }
1779                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1780                    self.advance();
1781                    if !matches!(self.peek(), Token::LParen) {
1782                        return Err(self.err(alloc::format!(
1783                            "expected '(' after CHECK in DOMAIN, got {:?}",
1784                            self.peek()
1785                        )));
1786                    }
1787                    self.advance();
1788                    let expr = self.parse_expr(0)?;
1789                    if !matches!(self.peek(), Token::RParen) {
1790                        return Err(self.err(alloc::format!(
1791                            "expected ')' after CHECK expr, got {:?}",
1792                            self.peek()
1793                        )));
1794                    }
1795                    self.advance();
1796                    checks.push(expr);
1797                }
1798                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1799                // prefix on the constraint; we drop the name and
1800                // recurse into the constraint parsing.
1801                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1802                    self.advance();
1803                    let _ = self.expect_ident_like()?;
1804                }
1805                _ => break,
1806            }
1807        }
1808        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1809            name,
1810            base_type,
1811            default,
1812            not_null,
1813            checks,
1814        }))
1815    }
1816
1817    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1818    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1819    /// consumed.
1820    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1821        let name = self.expect_ident_like()?;
1822        // Required `AS`.
1823        if !matches!(self.peek(), Token::As) {
1824            return Err(self.err(alloc::format!(
1825                "expected AS after CREATE TYPE {name:?}, got {:?}",
1826                self.peek()
1827            )));
1828        }
1829        self.advance();
1830        // v7.37.x (ζ-B composite Phase 1) — `AS (` is the composite-
1831        // type shape: `CREATE TYPE foo AS (a INT, b TEXT)`. Branch
1832        // on the next token: `(` = composite, ident `ENUM` = enum.
1833        if matches!(self.peek(), Token::LParen) {
1834            self.advance();
1835            let mut fields: Vec<(String, ColumnTypeName)> = Vec::new();
1836            loop {
1837                let field_name = self.expect_ident_like()?;
1838                let field_type = self.parse_column_type_name()?;
1839                fields.push((field_name, field_type));
1840                if matches!(self.peek(), Token::Comma) {
1841                    self.advance();
1842                    continue;
1843                }
1844                if matches!(self.peek(), Token::RParen) {
1845                    self.advance();
1846                    break;
1847                }
1848                return Err(self.err(alloc::format!(
1849                    "expected , or ) in composite field list, got {:?}",
1850                    self.peek()
1851                )));
1852            }
1853            if fields.is_empty() {
1854                return Err(self.err("CREATE TYPE … AS (…) must declare at least one field".into()));
1855            }
1856            return Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1857                name,
1858                kind: crate::ast::TypeKind::Composite { fields },
1859            }));
1860        }
1861        // Required `ENUM` ident.
1862        let kind_ident = match self.peek().clone() {
1863            Token::Ident(s) | Token::QuotedIdent(s) => s,
1864            other => {
1865                return Err(self.err(alloc::format!(
1866                    "expected ENUM or '(' after CREATE TYPE {name:?} AS, got {other:?}"
1867                )));
1868            }
1869        };
1870        if !kind_ident.eq_ignore_ascii_case("enum") {
1871            return Err(self.err(alloc::format!(
1872                "Phase 1.4 only supports ENUM or composite '(…)'; got {kind_ident:?}"
1873            )));
1874        }
1875        self.advance();
1876        if !matches!(self.peek(), Token::LParen) {
1877            return Err(self.err(alloc::format!(
1878                "expected '(' after ENUM, got {:?}",
1879                self.peek()
1880            )));
1881        }
1882        self.advance();
1883        let mut labels: Vec<String> = Vec::new();
1884        loop {
1885            match self.peek().clone() {
1886                Token::String(s) => {
1887                    self.advance();
1888                    labels.push(s);
1889                }
1890                other => {
1891                    return Err(
1892                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1893                    );
1894                }
1895            }
1896            if matches!(self.peek(), Token::Comma) {
1897                self.advance();
1898                continue;
1899            }
1900            if matches!(self.peek(), Token::RParen) {
1901                self.advance();
1902                break;
1903            }
1904            return Err(self.err(alloc::format!(
1905                "expected , or ) in ENUM label list, got {:?}",
1906                self.peek()
1907            )));
1908        }
1909        if labels.is_empty() {
1910            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
1911        }
1912        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1913            name,
1914            kind: crate::ast::TypeKind::Enum { labels },
1915        }))
1916    }
1917
1918    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
1919    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
1920    /// The `CREATE MATERIALIZED VIEW` keywords have already been
1921    /// consumed.
1922    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
1923        let if_not_exists = self.parse_if_not_exists();
1924        let name = self.expect_ident_like()?;
1925        let mut columns: Vec<String> = Vec::new();
1926        if matches!(self.peek(), Token::LParen) {
1927            self.advance();
1928            loop {
1929                let c = self.expect_ident_like()?;
1930                columns.push(c);
1931                if matches!(self.peek(), Token::Comma) {
1932                    self.advance();
1933                    continue;
1934                }
1935                if matches!(self.peek(), Token::RParen) {
1936                    self.advance();
1937                    break;
1938                }
1939                return Err(self.err(alloc::format!(
1940                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
1941                    self.peek()
1942                )));
1943            }
1944        }
1945        if !matches!(self.peek(), Token::As) {
1946            return Err(self.err(alloc::format!(
1947                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
1948                self.peek()
1949            )));
1950        }
1951        self.advance();
1952        let body_stmt = self.parse_select_stmt()?;
1953        let Statement::Select(body) = body_stmt else {
1954            return Err(self.err(alloc::format!(
1955                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
1956            )));
1957        };
1958        // Optional trailing `WITH [NO] DATA`.
1959        let with_data = self.parse_optional_with_data(true)?;
1960        Ok(Statement::CreateMaterializedView(
1961            crate::ast::CreateMaterializedViewStatement {
1962                name,
1963                if_not_exists,
1964                columns,
1965                body,
1966                with_data,
1967            },
1968        ))
1969    }
1970
1971    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
1972    /// `default_when_absent` is what to return if the tail is
1973    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
1974    /// WITH DATA).
1975    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
1976        let save = self.pos;
1977        // `WITH` is an Ident (not reserved in the lexer).
1978        let is_with = match self.peek() {
1979            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
1980            _ => false,
1981        };
1982        if !is_with {
1983            return Ok(default_when_absent);
1984        }
1985        self.advance();
1986        // Optional `NO`.
1987        let mut with_data = true;
1988        let is_no = match self.peek() {
1989            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
1990            _ => false,
1991        };
1992        if is_no {
1993            self.advance();
1994            with_data = false;
1995        }
1996        // Required `DATA` ident.
1997        let is_data = match self.peek() {
1998            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
1999            _ => false,
2000        };
2001        if is_data {
2002            self.advance();
2003            Ok(with_data)
2004        } else {
2005            // Caller's WITH wasn't WITH-DATA — rewind so the outer
2006            // parser can interpret it.
2007            self.pos = save;
2008            Ok(default_when_absent)
2009        }
2010    }
2011
2012    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
2013    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
2014    /// All keyword prefixes have already been consumed; the flags
2015    /// say which were present.
2016    fn parse_create_view_after_keyword(
2017        &mut self,
2018        or_replace: bool,
2019        _materialized_unused: bool,
2020        temporary: bool,
2021    ) -> Result<Statement, ParseError> {
2022        let if_not_exists = self.parse_if_not_exists();
2023        let name = self.expect_ident_like()?;
2024        // Optional `(col, col, …)` rename list.
2025        let mut columns: Vec<String> = Vec::new();
2026        if matches!(self.peek(), Token::LParen) {
2027            self.advance();
2028            loop {
2029                let c = self.expect_ident_like()?;
2030                columns.push(c);
2031                if matches!(self.peek(), Token::Comma) {
2032                    self.advance();
2033                    continue;
2034                }
2035                if matches!(self.peek(), Token::RParen) {
2036                    self.advance();
2037                    break;
2038                }
2039                return Err(self.err(alloc::format!(
2040                    "expected , or ) in VIEW column list, got {:?}",
2041                    self.peek()
2042                )));
2043            }
2044        }
2045        // Required `AS`.
2046        if !matches!(self.peek(), Token::As) {
2047            return Err(self.err(alloc::format!(
2048                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
2049                self.peek()
2050            )));
2051        }
2052        self.advance();
2053        // Body: a regular SELECT statement.
2054        let body_stmt = self.parse_select_stmt()?;
2055        let Statement::Select(body) = body_stmt else {
2056            return Err(self.err(alloc::format!(
2057                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
2058            )));
2059        };
2060        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
2061            name,
2062            or_replace,
2063            if_not_exists,
2064            temporary,
2065            columns,
2066            body,
2067        }))
2068    }
2069
2070    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
2071    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
2072    /// consumed; `temporary` carries whether TEMPORARY was seen.
2073    fn parse_create_sequence_after_keyword(
2074        &mut self,
2075        temporary: bool,
2076    ) -> Result<Statement, ParseError> {
2077        let if_not_exists = self.parse_if_not_exists();
2078        let name = self.expect_ident_like()?;
2079        // Optional `AS data_type`.
2080        let data_type = if matches!(self.peek(), Token::As) {
2081            self.advance();
2082            Some(self.parse_sequence_data_type()?)
2083        } else {
2084            None
2085        };
2086        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
2087        Ok(Statement::CreateSequence(
2088            crate::ast::CreateSequenceStatement {
2089                name,
2090                if_not_exists,
2091                temporary,
2092                data_type,
2093                options,
2094            },
2095        ))
2096    }
2097
2098    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
2099    /// already been consumed; this is reached after `SEQUENCE`.
2100    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
2101        let if_exists = self.parse_if_exists();
2102        let name = self.expect_ident_like()?;
2103        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
2104        Ok(Statement::AlterSequence(
2105            crate::ast::AlterSequenceStatement {
2106                name,
2107                if_exists,
2108                options,
2109            },
2110        ))
2111    }
2112
2113    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
2114        let kw = self.expect_ident_like()?;
2115        match kw.to_ascii_lowercase().as_str() {
2116            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
2117            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
2118            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
2119            other => Err(self.err(alloc::format!(
2120                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
2121            ))),
2122        }
2123    }
2124
2125    fn parse_sequence_options(
2126        &mut self,
2127        allow_restart: bool,
2128    ) -> Result<crate::ast::SequenceOptions, ParseError> {
2129        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
2130        let mut opts = SequenceOptions::default();
2131        #[allow(clippy::while_let_loop)]
2132        loop {
2133            // Match an ident; stop at any non-ident token (sentinel,
2134            // semicolon, end of statement).
2135            let kw_lc = match self.peek() {
2136                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
2137                _ => break,
2138            };
2139            match kw_lc.as_str() {
2140                "increment" => {
2141                    self.advance();
2142                    // Optional BY.
2143                    if matches!(self.peek(), Token::By) {
2144                        self.advance();
2145                    }
2146                    opts.increment = Some(self.expect_signed_int()?);
2147                }
2148                "minvalue" => {
2149                    self.advance();
2150                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
2151                }
2152                "maxvalue" => {
2153                    self.advance();
2154                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
2155                }
2156                "no" => {
2157                    self.advance();
2158                    let what = self.expect_ident_like()?;
2159                    match what.to_ascii_lowercase().as_str() {
2160                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
2161                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
2162                        "cycle" => opts.cycle = Some(false),
2163                        other => {
2164                            return Err(self.err(alloc::format!(
2165                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
2166                            )));
2167                        }
2168                    }
2169                }
2170                "start" => {
2171                    self.advance();
2172                    // Optional WITH.
2173                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2174                        if s.eq_ignore_ascii_case("with"))
2175                    {
2176                        self.advance();
2177                    }
2178                    opts.start = Some(self.expect_signed_int()?);
2179                }
2180                "restart" if allow_restart => {
2181                    self.advance();
2182                    // Optional WITH n; bare RESTART means restart at START.
2183                    let mut with_val: Option<i64> = None;
2184                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2185                        if s.eq_ignore_ascii_case("with"))
2186                    {
2187                        self.advance();
2188                        with_val = Some(self.expect_signed_int()?);
2189                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
2190                        with_val = Some(self.expect_signed_int()?);
2191                    }
2192                    opts.restart = Some(with_val);
2193                }
2194                "cache" => {
2195                    self.advance();
2196                    opts.cache = Some(self.expect_signed_int()?);
2197                }
2198                "cycle" => {
2199                    self.advance();
2200                    opts.cycle = Some(true);
2201                }
2202                "owned" => {
2203                    self.advance();
2204                    // BY is a reserved Token::By; accept either form.
2205                    match self.peek() {
2206                        Token::By => {
2207                            self.advance();
2208                        }
2209                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
2210                            self.advance();
2211                        }
2212                        other => {
2213                            return Err(
2214                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
2215                            );
2216                        }
2217                    }
2218                    // OWNED BY {NONE | tab.col}. Read just one ident
2219                    // (NOT expect_ident_like which would auto-strip
2220                    // a schema prefix and consume the `.col` we need).
2221                    let first = match self.advance() {
2222                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2223                        other => {
2224                            return Err(self.err(alloc::format!(
2225                                "expected identifier or NONE after OWNED BY, got {other:?}"
2226                            )));
2227                        }
2228                    };
2229                    if first.eq_ignore_ascii_case("none") {
2230                        opts.owned_by = Some(SequenceOwnedBy::None);
2231                    } else if matches!(self.peek(), Token::Dot) {
2232                        self.advance();
2233                        let second = match self.advance() {
2234                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2235                            other => {
2236                                return Err(self.err(alloc::format!(
2237                                    "expected column name after OWNED BY {first}., got {other:?}"
2238                                )));
2239                            }
2240                        };
2241                        // v7.17 dump-compat fix — pg_dump emits
2242                        // OWNED BY clauses as
2243                        // `schema.table.column` (three segments).
2244                        // If a third `.<ident>` follows, treat the
2245                        // first ident as schema (drop it; SPG is
2246                        // single-schema) and the middle / last
2247                        // pair as table.column. Otherwise it's
2248                        // the two-segment form table.column.
2249                        if matches!(self.peek(), Token::Dot) {
2250                            self.advance();
2251                            let third = match self.advance() {
2252                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2253                                other => {
2254                                    return Err(self.err(alloc::format!(
2255                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2256                                    )));
2257                                }
2258                            };
2259                            let _ = first; // schema prefix discarded
2260                            opts.owned_by = Some(SequenceOwnedBy::Column {
2261                                table: second,
2262                                column: third,
2263                            });
2264                        } else {
2265                            opts.owned_by = Some(SequenceOwnedBy::Column {
2266                                table: first,
2267                                column: second,
2268                            });
2269                        }
2270                    } else {
2271                        return Err(self.err(alloc::format!(
2272                            "expected table.column or NONE after OWNED BY, got {first:?}"
2273                        )));
2274                    }
2275                }
2276                _ => break,
2277            }
2278        }
2279        Ok(opts)
2280    }
2281
2282    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2283        let neg = if matches!(self.peek(), Token::Minus) {
2284            self.advance();
2285            true
2286        } else {
2287            false
2288        };
2289        match self.peek() {
2290            Token::Integer(n) => {
2291                let v = *n;
2292                self.advance();
2293                Ok(if neg { -v } else { v })
2294            }
2295            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2296        }
2297    }
2298
2299    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2300    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2301    /// clause is fully accepted and discarded — SPG always runs
2302    /// constraint checks immediately (single-writer model). The
2303    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2304    /// in either order (per the SQL spec they're independent),
2305    /// though pg_dump always emits them in the canonical
2306    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2307    /// Stops at the first token that isn't part of the clause.
2308    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2309        loop {
2310            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2311            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2312                self.advance();
2313                self.consume_optional_initially_clause()?;
2314                continue;
2315            }
2316            // `NOT DEFERRABLE` — already worked pre-3.1.
2317            if matches!(self.peek(), Token::Not) {
2318                let look = self.tokens.get(self.pos + 1);
2319                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2320                    self.advance(); // NOT
2321                    self.advance(); // DEFERRABLE
2322                    self.consume_optional_initially_clause()?;
2323                    continue;
2324                }
2325                break;
2326            }
2327            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2328            // accepts this without a leading [NOT] DEFERRABLE
2329            // (the timing keyword alone). pg_dump occasionally
2330            // emits it on FK constraints that inherit timing.
2331            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2332                self.consume_optional_initially_clause()?;
2333                continue;
2334            }
2335            break;
2336        }
2337        Ok(())
2338    }
2339
2340    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2341    /// next token is `INITIALLY`, consume it plus the required
2342    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2343    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2344        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2345            return Ok(());
2346        }
2347        self.advance(); // INITIALLY
2348        match self.advance() {
2349            Token::Ident(s)
2350                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2351            {
2352                Ok(())
2353            }
2354            other => Err(self.err(alloc::format!(
2355                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2356            ))),
2357        }
2358    }
2359
2360    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2361    /// in its entirety so the parser returns Empty without
2362    /// touching the runtime. The CREATE+PROCEDURE keywords are
2363    /// already consumed; this swallows everything from the
2364    /// procedure name through the matching `END`, including
2365    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2366    /// (DELIMITER `//` makes the script splitter forward the
2367    /// whole block as one statement), `@var` session-variable
2368    /// references, and the trailing terminator.
2369    ///
2370    /// Tracks nesting depth so:
2371    ///   BEGIN
2372    ///     IF cond THEN
2373    ///       BEGIN ... END;
2374    ///     END IF;
2375    ///   END
2376    /// terminates at the outer END.
2377    fn consume_mysql_routine_body(&mut self) {
2378        // Outer skeleton: name, (...), optional clauses, BEGIN
2379        // <body> END [;]. Scan for the first BEGIN — anything
2380        // before it is signature decoration we don't care about.
2381        // Once inside BEGIN, count up on BEGIN, down on END.
2382        let mut depth: i32 = 0;
2383        let mut started = false;
2384        loop {
2385            match self.peek().clone() {
2386                Token::Begin => {
2387                    self.advance();
2388                    depth += 1;
2389                    started = true;
2390                }
2391                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2392                    self.advance();
2393                    if started {
2394                        depth -= 1;
2395                        if depth <= 0 {
2396                            // Optional trailing ident (`END IF`,
2397                            // `END LOOP`, `END WHILE`, `END CASE`,
2398                            // `END label_name`) — eat the next
2399                            // ident if present so we don't
2400                            // mistake `END IF;` for the outer
2401                            // close.
2402                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2403                                // If the next token is one of the
2404                                // PL/SQL block-closer keywords,
2405                                // the END belongs to an inner
2406                                // block; bump depth back up.
2407                                let is_inner_close = matches!(
2408                                    self.peek(),
2409                                    Token::Ident(s) | Token::QuotedIdent(s)
2410                                        if matches!(
2411                                            s.to_ascii_lowercase().as_str(),
2412                                            "if" | "loop" | "while" | "case" | "repeat"
2413                                        )
2414                                );
2415                                if is_inner_close {
2416                                    self.advance();
2417                                    depth += 1;
2418                                    continue;
2419                                }
2420                            }
2421                            // Eat optional trailing `;`.
2422                            if matches!(self.peek(), Token::Semicolon) {
2423                                self.advance();
2424                            }
2425                            return;
2426                        }
2427                    }
2428                }
2429                Token::Eof => return,
2430                _ => {
2431                    self.advance();
2432                }
2433            }
2434        }
2435    }
2436
2437    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2438    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2439    ///
2440    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2441    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2442    ///   ident, or `ident @ ident-or-quoted-string` host form)
2443    /// * `SQL SECURITY {DEFINER|INVOKER}`
2444    ///
2445    /// Each clause may appear at most once but in any order.
2446    /// The hints are pure planner / permission metadata that
2447    /// SPG's view-rewrite engine handles uniformly; we accept
2448    /// and discard. Returns `Ok(())` once a non-clause token is
2449    /// peeked (the caller then checks for the `VIEW` keyword).
2450    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2451        loop {
2452            match self.peek().clone() {
2453                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2454                    self.advance(); // ALGORITHM
2455                    // Optional `=`. MySQL spec requires it but be
2456                    // generous.
2457                    if matches!(self.peek(), Token::Eq) {
2458                        self.advance();
2459                    }
2460                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2461                    // bare ident; unknown values still parse so
2462                    // future MySQL versions don't break.
2463                    if matches!(
2464                        self.peek(),
2465                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2466                    ) {
2467                        self.advance();
2468                    }
2469                }
2470                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2471                    self.advance(); // DEFINER
2472                    if matches!(self.peek(), Token::Eq) {
2473                        self.advance();
2474                    }
2475                    // User: quoted string, ident, OR ident @ host
2476                    // (host may itself be quoted or bare).
2477                    match self.peek().clone() {
2478                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2479                            self.advance();
2480                            // Optional `@host`.
2481                            if matches!(self.peek(), Token::At) {
2482                                self.advance();
2483                                if matches!(
2484                                    self.peek(),
2485                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2486                                ) {
2487                                    self.advance();
2488                                }
2489                            }
2490                        }
2491                        _ => {}
2492                    }
2493                }
2494                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2495                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2496                    // when followed by SECURITY — the dispatcher must
2497                    // not consume a bare `SQL` token (it's not a
2498                    // legal CREATE prefix on its own).
2499                    let save = self.pos;
2500                    self.advance(); // SQL
2501                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2502                        if s2.eq_ignore_ascii_case("security"))
2503                    {
2504                        self.advance(); // SECURITY
2505                        // DEFINER / INVOKER trailing ident.
2506                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2507                            self.advance();
2508                        }
2509                    } else {
2510                        // Not a SQL SECURITY clause — roll back and
2511                        // bail; the caller will error out cleanly.
2512                        self.pos = save;
2513                        return Ok(());
2514                    }
2515                }
2516                _ => return Ok(()),
2517            }
2518        }
2519    }
2520
2521    fn parse_if_not_exists(&mut self) -> bool {
2522        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2523        {
2524            let save = self.pos;
2525            self.advance();
2526            if matches!(self.peek(), Token::Not) {
2527                self.advance();
2528                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2529                {
2530                    self.advance();
2531                    return true;
2532                }
2533            }
2534            self.pos = save;
2535        }
2536        false
2537    }
2538
2539    fn parse_if_exists(&mut self) -> bool {
2540        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2541        {
2542            let save = self.pos;
2543            self.advance();
2544            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2545            {
2546                self.advance();
2547                return true;
2548            }
2549            self.pos = save;
2550        }
2551        false
2552    }
2553
2554    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2555    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2556    /// been consumed.
2557    fn parse_create_trigger_after_keyword(
2558        &mut self,
2559        or_replace: bool,
2560    ) -> Result<Statement, ParseError> {
2561        let name = self.expect_ident_like()?;
2562        let timing = {
2563            let ident = self.expect_ident_like()?;
2564            if ident.eq_ignore_ascii_case("before") {
2565                TriggerTiming::Before
2566            } else if ident.eq_ignore_ascii_case("after") {
2567                TriggerTiming::After
2568            } else if ident.eq_ignore_ascii_case("instead") {
2569                let next = self.expect_ident_like()?;
2570                if !next.eq_ignore_ascii_case("of") {
2571                    return Err(self.err(alloc::format!(
2572                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2573                    )));
2574                }
2575                TriggerTiming::InsteadOf
2576            } else {
2577                return Err(self.err(alloc::format!(
2578                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2579                )));
2580            }
2581        };
2582        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2583        // OR is a reserved keyword token (Token::Or), not an Ident.
2584        // v7.13.0 — after an UPDATE event we may optionally see
2585        // `OF col, col, …` (mailrs round-5 G7). Columns are
2586        // captured into `update_columns` once across the whole
2587        // events list; multiple `UPDATE OF` clauses are rejected.
2588        let mut events: Vec<TriggerEvent> = Vec::new();
2589        let mut update_columns: Vec<String> = Vec::new();
2590        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2591        events.push(first_ev);
2592        if !first_cols.is_empty() {
2593            update_columns = first_cols;
2594        }
2595        while matches!(self.peek(), Token::Or) {
2596            self.advance();
2597            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2598            events.push(ev);
2599            if !cols.is_empty() {
2600                if !update_columns.is_empty() {
2601                    return Err(
2602                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2603                    );
2604                }
2605                update_columns = cols;
2606            }
2607        }
2608        // ON <table>
2609        let tok = self.peek();
2610        let Token::On = tok else {
2611            return Err(self.err(alloc::format!(
2612                "expected ON after trigger events, got {tok:?}"
2613            )));
2614        };
2615        self.advance();
2616        let table = self.expect_ident_like()?;
2617        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2618        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2619        // idents.
2620        if !matches!(self.peek(), Token::For) {
2621            return Err(self.err(alloc::format!(
2622                "expected FOR EACH ROW / STATEMENT, got {:?}",
2623                self.peek()
2624            )));
2625        }
2626        self.advance();
2627        let for_each = {
2628            let e = self.expect_ident_like()?;
2629            if !e.eq_ignore_ascii_case("each") {
2630                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2631            }
2632            let unit = self.expect_ident_like()?;
2633            if unit.eq_ignore_ascii_case("row") {
2634                TriggerForEach::Row
2635            } else if unit.eq_ignore_ascii_case("statement") {
2636                TriggerForEach::Statement
2637            } else {
2638                return Err(self.err(alloc::format!(
2639                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2640                )));
2641            }
2642        };
2643        // EXECUTE FUNCTION/PROCEDURE name(...)
2644        let exec = self.expect_ident_like()?;
2645        if !exec.eq_ignore_ascii_case("execute") {
2646            return Err(self.err(alloc::format!(
2647                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2648            )));
2649        }
2650        let fn_or_proc = self.expect_ident_like()?;
2651        if !(fn_or_proc.eq_ignore_ascii_case("function")
2652            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2653        {
2654            return Err(self.err(alloc::format!(
2655                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2656            )));
2657        }
2658        let function = self.expect_ident_like()?;
2659        // Optional empty arg list `()`.
2660        if matches!(self.peek(), Token::LParen) {
2661            self.advance();
2662            if !matches!(self.peek(), Token::RParen) {
2663                return Err(self.err(alloc::format!(
2664                    "v7.12.4 trigger function calls take no args; got {:?}",
2665                    self.peek()
2666                )));
2667            }
2668            self.advance();
2669        }
2670        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2671            name,
2672            or_replace,
2673            timing,
2674            events,
2675            table,
2676            for_each,
2677            function,
2678            update_columns,
2679        }))
2680    }
2681
2682    /// v7.13.0 — parse one trigger event, then optionally consume
2683    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2684    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2685    fn parse_trigger_event_with_optional_of(
2686        &mut self,
2687    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2688        let ev = self.parse_trigger_event()?;
2689        if !matches!(ev, TriggerEvent::Update) {
2690            return Ok((ev, Vec::new()));
2691        }
2692        // `OF` is a bare ident.
2693        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2694            return Ok((ev, Vec::new()));
2695        }
2696        self.advance(); // OF
2697        let mut cols: Vec<String> = Vec::new();
2698        loop {
2699            cols.push(self.expect_ident_like()?);
2700            if matches!(self.peek(), Token::Comma) {
2701                self.advance();
2702                continue;
2703            }
2704            break;
2705        }
2706        if cols.is_empty() {
2707            return Err(
2708                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2709            );
2710        }
2711        Ok((ev, cols))
2712    }
2713
2714    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2715    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2716    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2717    /// inside the body.
2718    /// Called by [`parse_plpgsql_body`] after the body's tokens
2719    /// have been lexed into this temporary parser.
2720    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2721        // v7.12.6 — optional DECLARE prelude.
2722        let declarations = if matches!(
2723            self.peek(),
2724            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2725        ) {
2726            self.advance();
2727            self.parse_plpgsql_declare_block()?
2728        } else {
2729            Vec::new()
2730        };
2731        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2732        // `BEGIN` transaction-start, but we can reuse the
2733        // reserved Token::Begin since the body is a separate
2734        // lex/parse context).
2735        if !matches!(self.peek(), Token::Begin) {
2736            return Err(self.err(alloc::format!(
2737                "expected BEGIN at start of plpgsql block, got {:?}",
2738                self.peek()
2739            )));
2740        }
2741        self.advance();
2742        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2743        Ok(PlPgSqlBlock {
2744            declarations,
2745            statements,
2746        })
2747    }
2748
2749    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2750    /// prelude. Caller has already consumed `DECLARE`. We stop
2751    /// reading entries when we hit `BEGIN`.
2752    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2753        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2754        loop {
2755            if matches!(self.peek(), Token::Begin) {
2756                return Ok(out);
2757            }
2758            let name = self.expect_ident_like()?;
2759            let ty_token = self.expect_ident_like()?;
2760            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2761                Some(t) => FunctionArgType::Typed(t),
2762                None => FunctionArgType::Raw(ty_token),
2763            };
2764            let default = match self.peek() {
2765                Token::ColonEq => {
2766                    self.advance();
2767                    Some(self.parse_expr(0)?)
2768                }
2769                Token::Eq => {
2770                    // PL/pgSQL also accepts `=` for the
2771                    // DECLARE default (PG treats them the same
2772                    // in this position).
2773                    self.advance();
2774                    Some(self.parse_expr(0)?)
2775                }
2776                _ => None,
2777            };
2778            // Mandatory `;` between declarations.
2779            if !matches!(self.peek(), Token::Semicolon) {
2780                return Err(self.err(alloc::format!(
2781                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2782                    self.peek()
2783                )));
2784            }
2785            self.advance();
2786            out.push(PlPgSqlDeclare { name, ty, default });
2787        }
2788    }
2789
2790    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2791    /// the terminating `END;` (or `END IF;` etc — handled by the
2792    /// per-construct sub-parsers). Used by both the outer block
2793    /// and the IF/ELSE branch bodies.
2794    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2795        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2796        loop {
2797            // Allow trailing semicolons + END.
2798            while matches!(self.peek(), Token::Semicolon) {
2799                self.advance();
2800            }
2801            // END / ELSE / ELSIF — handled by the caller.
2802            if matches!(
2803                self.peek(),
2804                Token::Ident(s) | Token::QuotedIdent(s)
2805                    if s.eq_ignore_ascii_case("end")
2806                        || s.eq_ignore_ascii_case("else")
2807                        || s.eq_ignore_ascii_case("elsif")
2808                        || s.eq_ignore_ascii_case("elseif")
2809            ) {
2810                return Ok(statements);
2811            }
2812            // Otherwise: one statement, then expect `;` or
2813            // a block-terminator keyword.
2814            let stmt = self.parse_plpgsql_stmt()?;
2815            statements.push(stmt);
2816            match self.peek() {
2817                Token::Semicolon => {
2818                    self.advance();
2819                }
2820                Token::Ident(s) | Token::QuotedIdent(s)
2821                    if s.eq_ignore_ascii_case("end")
2822                        || s.eq_ignore_ascii_case("else")
2823                        || s.eq_ignore_ascii_case("elsif")
2824                        || s.eq_ignore_ascii_case("elseif") =>
2825                {
2826                    // Final statement of the block without `;`.
2827                }
2828                other => {
2829                    return Err(self.err(alloc::format!(
2830                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2831                    )));
2832                }
2833            }
2834        }
2835    }
2836
2837    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2838        // RETURN keyword?
2839        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2840        {
2841            self.advance();
2842            return self.parse_plpgsql_return();
2843        }
2844        // v7.12.6 — IF block.
2845        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2846        {
2847            self.advance();
2848            return self.parse_plpgsql_if();
2849        }
2850        // v7.12.6 — RAISE.
2851        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2852        {
2853            self.advance();
2854            return self.parse_plpgsql_raise();
2855        }
2856        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2857        // plpgsql-specific shape (mailrs round-10 migrate-042).
2858        // PG's SELECT INTO at top-level SQL would CREATE a new
2859        // table; inside plpgsql it ASSIGNS the query result to
2860        // a local variable. We detect the INTO at paren-depth
2861        // 0 between SELECT and the statement boundary; if
2862        // found, split the token stream into "pre-INTO
2863        // projection" + "var" + "post-INTO FROM/WHERE…" and
2864        // rebuild as a SelectInto with a regular SELECT body
2865        // (no INTO clause).
2866        if matches!(self.peek(), Token::Select)
2867            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2868        {
2869            return Ok(PlPgSqlStmt::SelectInto {
2870                var: var_name,
2871                body: Box::new(select_body),
2872            });
2873        }
2874        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2875        // SELECT can appear directly inside a trigger body; we
2876        // recurse into the regular Statement parser, which will
2877        // stop at the trailing `;` (which our caller then
2878        // consumes).
2879        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2880        // also embed ALTER / CREATE / DROP statements; route
2881        // those through the same parser so the DO body parses
2882        // cleanly.
2883        if matches!(self.peek(), Token::Insert)
2884            || matches!(self.peek(), Token::Select)
2885            || matches!(self.peek(), Token::Create)
2886            || matches!(self.peek(), Token::Drop)
2887            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2888                if s.eq_ignore_ascii_case("update")
2889                    || s.eq_ignore_ascii_case("delete")
2890                    || s.eq_ignore_ascii_case("alter"))
2891        {
2892            let stmt = self.parse_one_statement()?;
2893            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2894        }
2895        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
2896        // followed by `:=` and an expression.
2897        let target = self.parse_plpgsql_assign_target()?;
2898        // PL/pgSQL assignment uses `:=`. The lexer represents
2899        // this as a colon followed by `=`; check both shapes.
2900        match self.peek() {
2901            Token::ColonEq => {
2902                self.advance();
2903            }
2904            Token::Colon => {
2905                self.advance();
2906                if !matches!(self.peek(), Token::Eq) {
2907                    return Err(self.err(alloc::format!(
2908                        "expected := after plpgsql assign target, got `:` then {:?}",
2909                        self.peek()
2910                    )));
2911                }
2912                self.advance();
2913            }
2914            other => {
2915                return Err(self.err(alloc::format!(
2916                    "expected := after plpgsql assign target, got {other:?}"
2917                )));
2918            }
2919        }
2920        let value = self.parse_expr(0)?;
2921        Ok(PlPgSqlStmt::Assign { target, value })
2922    }
2923
2924    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
2925    /// [ELSE body] END IF`. `IF` keyword already consumed.
2926    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2927        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
2928        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
2929        loop {
2930            // <expr> THEN
2931            let cond = self.parse_expr(0)?;
2932            let then_kw = self.expect_ident_like()?;
2933            if !then_kw.eq_ignore_ascii_case("then") {
2934                return Err(self.err(alloc::format!(
2935                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
2936                )));
2937            }
2938            let body = self.parse_plpgsql_stmt_list_until_end()?;
2939            branches.push((cond, body));
2940            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
2941            match self.peek() {
2942                Token::Ident(s) | Token::QuotedIdent(s)
2943                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
2944                {
2945                    self.advance();
2946                    continue;
2947                }
2948                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
2949                    self.advance();
2950                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
2951                    break;
2952                }
2953                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2954                    break;
2955                }
2956                other => {
2957                    return Err(self.err(alloc::format!(
2958                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
2959                    )));
2960                }
2961            }
2962        }
2963        // Expect `END IF` (the END keyword is the one we're
2964        // looking at right now).
2965        let end_kw = self.expect_ident_like()?;
2966        if !end_kw.eq_ignore_ascii_case("end") {
2967            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
2968        }
2969        let if_kw = self.expect_ident_like()?;
2970        if !if_kw.eq_ignore_ascii_case("if") {
2971            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
2972        }
2973        Ok(PlPgSqlStmt::If {
2974            branches,
2975            else_branch,
2976        })
2977    }
2978
2979    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
2980    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
2981    /// is already consumed.
2982    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2983        let lvl_ident = self.expect_ident_like()?;
2984        let level = match lvl_ident.to_ascii_lowercase().as_str() {
2985            "notice" => RaiseLevel::Notice,
2986            "warning" => RaiseLevel::Warning,
2987            "info" => RaiseLevel::Info,
2988            "log" => RaiseLevel::Log,
2989            "debug" => RaiseLevel::Debug,
2990            "exception" => RaiseLevel::Exception,
2991            other => {
2992                return Err(self.err(alloc::format!(
2993                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
2994                )));
2995            }
2996        };
2997        // Message: required for v7.12.6. PG accepts a bare
2998        // RAISE-rethrow form (no message), reserved for future
2999        // RAISE-no-args support.
3000        let Token::String(msg) = self.peek() else {
3001            return Err(self.err(alloc::format!(
3002                "expected RAISE message string, got {:?}",
3003                self.peek()
3004            )));
3005        };
3006        let message = msg.clone();
3007        self.advance();
3008        // Optional comma-separated args (PG `%` format substitution).
3009        let mut args: Vec<Expr> = Vec::new();
3010        while matches!(self.peek(), Token::Comma) {
3011            self.advance();
3012            args.push(self.parse_expr(0)?);
3013        }
3014        Ok(PlPgSqlStmt::Raise {
3015            level,
3016            message,
3017            args,
3018        })
3019    }
3020
3021    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
3022    /// <projection> INTO <var> [FROM …]` (mailrs round-10
3023    /// migrate-042). Returns `(rebuilt_select_without_into,
3024    /// var_name)` when the pattern matches; `None` for
3025    /// regular SELECTs (those go through the embedded-SQL
3026    /// path). Token-stream surgery so the rebuilt SELECT
3027    /// parses through the regular `parse_select_stmt`.
3028    #[allow(clippy::too_many_lines)]
3029    fn try_parse_plpgsql_select_into(
3030        &mut self,
3031    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
3032        // Scan forward from `self.pos + 1` (past Token::Select)
3033        // for Token::Into at paren-depth 0, stopping at the
3034        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
3035        // end the plpgsql statement.
3036        let start = self.pos;
3037        let mut into_pos: Option<usize> = None;
3038        let mut depth: i32 = 0;
3039        let mut i = start + 1;
3040        while i < self.tokens.len() {
3041            match &self.tokens[i] {
3042                Token::LParen => depth += 1,
3043                Token::RParen => depth -= 1,
3044                Token::Semicolon if depth == 0 => break,
3045                Token::Ident(s)
3046                    if depth == 0
3047                        && (s.eq_ignore_ascii_case("end")
3048                            || s.eq_ignore_ascii_case("else")
3049                            || s.eq_ignore_ascii_case("elsif")) =>
3050                {
3051                    break;
3052                }
3053                Token::Into if depth == 0 => {
3054                    into_pos = Some(i);
3055                    break;
3056                }
3057                _ => {}
3058            }
3059            i += 1;
3060        }
3061        let Some(into_at) = into_pos else {
3062            return Ok(None);
3063        };
3064        // The token immediately after INTO must be the target
3065        // var ident; anything else (e.g. INSERT INTO table)
3066        // ruled out by the depth-0 check above. Capture it.
3067        let var = match self.tokens.get(into_at + 1) {
3068            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
3069            other => {
3070                return Err(self.err(alloc::format!(
3071                    "expected variable name after SELECT … INTO, got {other:?}"
3072                )));
3073            }
3074        };
3075        // Find the end of the plpgsql SELECT INTO statement —
3076        // same boundary rules as the depth-0 scan above.
3077        let mut end = into_at + 2;
3078        let mut depth2: i32 = 0;
3079        while end < self.tokens.len() {
3080            match &self.tokens[end] {
3081                Token::LParen => depth2 += 1,
3082                Token::RParen => depth2 -= 1,
3083                Token::Semicolon if depth2 == 0 => break,
3084                Token::Ident(s)
3085                    if depth2 == 0
3086                        && (s.eq_ignore_ascii_case("end")
3087                            || s.eq_ignore_ascii_case("else")
3088                            || s.eq_ignore_ascii_case("elsif")) =>
3089                {
3090                    break;
3091                }
3092                _ => {}
3093            }
3094            end += 1;
3095        }
3096        // Rebuild a token stream that represents the SELECT
3097        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
3098        // post-var tokens up to statement end]. Run the
3099        // regular `parse_select_stmt` against it.
3100        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
3101        for j in start..into_at {
3102            rebuilt.push(self.tokens[j].clone());
3103        }
3104        for j in (into_at + 2)..end {
3105            rebuilt.push(self.tokens[j].clone());
3106        }
3107        rebuilt.push(Token::Eof);
3108        let saved_pos = self.pos;
3109        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
3110        self.pos = 0;
3111        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
3112        if !matches!(self.peek(), Token::Select) {
3113            self.tokens = saved_tokens;
3114            self.pos = saved_pos;
3115            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
3116        }
3117        let sel = self.parse_select_stmt();
3118        self.tokens = saved_tokens;
3119        self.pos = end;
3120        let sel = sel?;
3121        let Statement::Select(body) = sel else {
3122            return Err(self.err(alloc::format!(
3123                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
3124            )));
3125        };
3126        Ok(Some((body, var)))
3127    }
3128
3129    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
3130        // v7.16.1 — read the head token DIRECTLY rather than
3131        // via `expect_ident_like`. The v7.14.0 schema-qualifier
3132        // strip (`public.t` → `t`) inside `expect_ident_like`
3133        // greedily consumes any `ident . ident` pair, which
3134        // silently turned every `NEW.col := …` /
3135        // `OLD.col := …` plpgsql assignment into a Local("col")
3136        // assignment — the head "new"/"old" was eaten as if it
3137        // were a schema name and the Dot was consumed too, so
3138        // this function's own `peek() == Token::Dot` check
3139        // below never fired. Every BEFORE trigger that rewrote
3140        // a NEW cell was a silent no-op for two major releases
3141        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
3142        // gate failures were investigated as v7.16.1 backlog.
3143        let head = match self.advance() {
3144            Token::Ident(s) | Token::QuotedIdent(s) => s,
3145            other => {
3146                return Err(self.err(alloc::format!(
3147                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
3148                )));
3149            }
3150        };
3151        if matches!(self.peek(), Token::Dot) {
3152            self.advance();
3153            let col = self.expect_ident_like()?;
3154            if head.eq_ignore_ascii_case("new") {
3155                return Ok(AssignTarget::NewColumn(col));
3156            }
3157            if head.eq_ignore_ascii_case("old") {
3158                return Ok(AssignTarget::OldColumn(col));
3159            }
3160            return Err(self.err(alloc::format!(
3161                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
3162                 got {head:?}.<col>"
3163            )));
3164        }
3165        Ok(AssignTarget::Local(head))
3166    }
3167
3168    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3169        // RETURN NEW / OLD / NULL — bare-ident forms.
3170        match self.peek() {
3171            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
3172                self.advance();
3173                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
3174            }
3175            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
3176                self.advance();
3177                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
3178            }
3179            Token::Null => {
3180                self.advance();
3181                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3182            }
3183            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
3184            // per PL/pgSQL convention.
3185            Token::Semicolon => {
3186                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3187            }
3188            _ => {}
3189        }
3190        // Fall through: parse a full expression.
3191        let e = self.parse_expr(0)?;
3192        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
3193    }
3194
3195    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
3196        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
3197        // are ident-shaped (the parser keys off case-insensitive
3198        // match — same shape used by the top-level Update / Delete
3199        // dispatchers at parse_one_statement).
3200        if matches!(self.peek(), Token::Insert) {
3201            self.advance();
3202            return Ok(TriggerEvent::Insert);
3203        }
3204        match self.peek() {
3205            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3206                self.advance();
3207                Ok(TriggerEvent::Update)
3208            }
3209            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3210                self.advance();
3211                Ok(TriggerEvent::Delete)
3212            }
3213            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
3214                self.advance();
3215                Ok(TriggerEvent::Truncate)
3216            }
3217            other => Err(self.err(alloc::format!(
3218                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3219            ))),
3220        }
3221    }
3222
3223    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3224    ///   - (no clause) → implicit `FOR ALL TABLES`
3225    ///   - `FOR ALL TABLES`
3226    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3227    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3228    ///     accepted (PG accepts both forms in PG 19).
3229    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3230        let name = self.expect_ident_or_string()?;
3231        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3232        // shape so existing publications keep parsing identically.
3233        let scope = if matches!(self.peek(), Token::For) {
3234            self.advance();
3235            if matches!(self.peek(), Token::All) {
3236                self.advance();
3237                if !matches!(self.peek(), Token::Tables) {
3238                    return Err(self.err(format!(
3239                        "expected TABLES after FOR ALL, got {:?}",
3240                        self.peek()
3241                    )));
3242                }
3243                self.advance();
3244                if matches!(self.peek(), Token::Except) {
3245                    self.advance();
3246                    let tables = self.parse_publication_table_list()?;
3247                    PublicationScope::AllTablesExcept(tables)
3248                } else {
3249                    PublicationScope::AllTables
3250                }
3251            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3252                // PG 19 accepts both `FOR TABLE …` (singular) and
3253                // `FOR TABLES …` (plural); SPG matches.
3254                self.advance();
3255                let tables = self.parse_publication_table_list()?;
3256                PublicationScope::ForTables(tables)
3257            } else {
3258                return Err(self.err(format!(
3259                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3260                    self.peek()
3261                )));
3262            }
3263        } else {
3264            PublicationScope::AllTables
3265        };
3266        Ok(Statement::CreatePublication(CreatePublicationStatement {
3267            name,
3268            scope,
3269        }))
3270    }
3271
3272    /// v6.1.3 — Comma-separated identifier list for the publication
3273    /// FOR-clause. Requires at least one entry; empty list is a
3274    /// parse error (PG behaviour). Quoted idents are accepted; the
3275    /// names round-trip through `Display` as `quote_ident(name)`.
3276    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3277        let first = self.expect_ident_like()?;
3278        let mut out = alloc::vec![first];
3279        while matches!(self.peek(), Token::Comma) {
3280            self.advance();
3281            out.push(self.expect_ident_like()?);
3282        }
3283        Ok(out)
3284    }
3285
3286    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3287    ///                 CONNECTION '<conn>'
3288    ///                 PUBLICATION <pub> [, <pub> ...]`.
3289    ///
3290    /// The clause order is fixed (CONNECTION first, then
3291    /// PUBLICATION) to match PG. No WITH-options accepted in
3292    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3293    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3294        let name = self.expect_ident_or_string()?;
3295        if !matches!(self.peek(), Token::Connection) {
3296            return Err(self.err(format!(
3297                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3298                self.peek()
3299            )));
3300        }
3301        self.advance();
3302        let conn_str = self.expect_string_literal()?;
3303        if !matches!(self.peek(), Token::Publication) {
3304            return Err(self.err(format!(
3305                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3306                self.peek()
3307            )));
3308        }
3309        self.advance();
3310        // Reuse the publication FOR-list parser shape: at least one
3311        // identifier, comma-separated.
3312        let first = self.expect_ident_like()?;
3313        let mut publications = alloc::vec![first];
3314        while matches!(self.peek(), Token::Comma) {
3315            self.advance();
3316            publications.push(self.expect_ident_like()?);
3317        }
3318        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3319            name,
3320            conn_str,
3321            publications,
3322        }))
3323    }
3324
3325    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3326    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3327    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3328    /// that fit `u64`.
3329    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3330    /// (`pg_catalog.default_text_search_config` etc).
3331    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3332        let mut name = self.expect_ident_like()?;
3333        while matches!(self.peek(), Token::Dot) {
3334            self.advance();
3335            let next = self.expect_ident_like()?;
3336            name.push('.');
3337            name.push_str(&next);
3338        }
3339        Ok(name.to_ascii_lowercase())
3340    }
3341
3342    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3343        match self.advance() {
3344            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3345            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3346                Ok(crate::ast::SetValue::Default)
3347            }
3348            Token::Ident(s) | Token::QuotedIdent(s) => {
3349                let mut accum = s;
3350                while matches!(self.peek(), Token::Dot) {
3351                    self.advance();
3352                    let next = self.expect_ident_like()?;
3353                    accum.push('.');
3354                    accum.push_str(&next);
3355                }
3356                Ok(crate::ast::SetValue::Ident(accum))
3357            }
3358            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3359            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3360            // v7.22 (mailrs round-13 gap 2) — PG boolean parameter
3361            // spellings that lex as keyword tokens, not idents:
3362            // `SET standard_conforming_strings = on` is in every
3363            // pg_dump preamble (`off` already lexes as an ident).
3364            Token::On => Ok(crate::ast::SetValue::Ident("on".to_string())),
3365            Token::True => Ok(crate::ast::SetValue::Ident("true".to_string())),
3366            Token::False => Ok(crate::ast::SetValue::Ident("false".to_string())),
3367            // v7.14.0 — MySQL session/user variable RHS
3368            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3369            // Wrap as Ident so the SET handler can record it; the
3370            // engine treats `@VAR` / `@@VAR` values as opaque
3371            // strings.
3372            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3373            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3374            // is the common MySQL preamble shape. Allow a `+` or
3375            // `-` prefix on negative numerics for parity with PG
3376            // (some param defaults are negative).
3377            Token::Minus => match self.advance() {
3378                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3379                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3380                other => Err(self.err(format!(
3381                    "expected numeric after `-` in SET value, got {other:?}"
3382                ))),
3383            },
3384            other => Err(self.err(format!(
3385                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3386            ))),
3387        }
3388    }
3389
3390    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3391        // FOR is a v6.1.2-reserved keyword (Token::For). The
3392        // other two are bare idents — they've never needed lexer
3393        // support and we keep it that way.
3394        if !matches!(self.peek(), Token::For) {
3395            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3396        }
3397        self.advance();
3398        self.expect_keyword_ident("wal")?;
3399        self.expect_keyword_ident("position")?;
3400        let pos = self.expect_u64_literal()?;
3401        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3402        {
3403            self.advance();
3404            self.expect_keyword_ident("timeout")?;
3405            Some(self.expect_u64_literal()?)
3406        } else {
3407            None
3408        };
3409        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3410    }
3411
3412    /// v6.1.7 helper — consume a `Token::Integer` and check it
3413    /// fits `u64`. WAL positions and millisecond timeouts are
3414    /// non-negative.
3415    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3416        match self.advance() {
3417            Token::Integer(n) if n >= 0 => Ok(n as u64),
3418            Token::Integer(n) => Err(ParseError {
3419                message: format!("expected non-negative integer, got {n}"),
3420                token_pos: self.pos.saturating_sub(1),
3421            }),
3422            other => Err(ParseError {
3423                message: format!("expected integer literal, got {other:?}"),
3424                token_pos: self.pos.saturating_sub(1),
3425            }),
3426        }
3427    }
3428
3429    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3430    /// ROLE '<role>' (defaults to readonly). All string slots accept
3431    /// either a quoted ident or a quoted string literal.
3432    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3433        let name = self.expect_ident_or_string()?;
3434        self.expect_keyword_ident("with")?;
3435        self.expect_keyword_ident("password")?;
3436        let password = self.expect_string_literal()?;
3437        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3438            && s.eq_ignore_ascii_case("role")
3439        {
3440            self.advance();
3441            self.expect_string_literal()?
3442        } else {
3443            "readonly".to_string()
3444        };
3445        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3446            name,
3447            password,
3448            role,
3449        }))
3450    }
3451
3452    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3453    /// Caller already consumed the leading `UPDATE` ident.
3454    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3455        let table = self.expect_ident_like()?;
3456        self.expect_keyword_ident("set")?;
3457        let mut assignments = Vec::new();
3458        loop {
3459            let col = self.expect_ident_like()?;
3460            if !matches!(self.peek(), Token::Eq) {
3461                return Err(self.err(format!(
3462                    "expected `=` after column name in UPDATE SET, got {:?}",
3463                    self.peek()
3464                )));
3465            }
3466            self.advance();
3467            let value = self.parse_expr(0)?;
3468            assignments.push((col, value));
3469            if matches!(self.peek(), Token::Comma) {
3470                self.advance();
3471                continue;
3472            }
3473            break;
3474        }
3475        let where_ = if matches!(self.peek(), Token::Where) {
3476            self.advance();
3477            Some(self.parse_expr(0)?)
3478        } else {
3479            None
3480        };
3481        let returning = self.parse_optional_returning()?;
3482        Ok(Statement::Update(crate::ast::UpdateStatement {
3483            ctes: Vec::new(),
3484            table,
3485            assignments,
3486            where_,
3487            returning,
3488        }))
3489    }
3490
3491    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3492    /// the leading `DELETE` ident.
3493    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3494        if !matches!(self.peek(), Token::From) {
3495            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3496        }
3497        self.advance();
3498        let table = self.expect_ident_like()?;
3499        let where_ = if matches!(self.peek(), Token::Where) {
3500            self.advance();
3501            Some(self.parse_expr(0)?)
3502        } else {
3503            None
3504        };
3505        let returning = self.parse_optional_returning()?;
3506        Ok(Statement::Delete(crate::ast::DeleteStatement {
3507            ctes: Vec::new(),
3508            table,
3509            where_,
3510            returning,
3511        }))
3512    }
3513
3514    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3515    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3516    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3517    /// keyword. v7.17 surface:
3518    ///   * source: table reference (subquery source is a follow-up)
3519    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3520    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3521    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3522    ///     order
3523    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3524        // INTO
3525        let is_into_kw = matches!(self.peek(), Token::Into)
3526            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3527        if !is_into_kw {
3528            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3529        }
3530        self.advance();
3531        let target = self.expect_ident_like()?;
3532        // Optional alias — bare ident before USING.
3533        let target_alias = match self.peek() {
3534            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3535                Some(self.expect_ident_like()?)
3536            }
3537            _ => None,
3538        };
3539        // USING
3540        let is_using_kw = matches!(
3541            self.peek(),
3542            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3543        );
3544        if !is_using_kw {
3545            return Err(self.err(format!(
3546                "expected USING after MERGE INTO target, got {:?}",
3547                self.peek()
3548            )));
3549        }
3550        self.advance();
3551        let source = self.expect_ident_like()?;
3552        let source_alias = match self.peek() {
3553            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3554                Some(self.expect_ident_like()?)
3555            }
3556            _ => None,
3557        };
3558        // ON
3559        if !matches!(self.peek(), Token::On) {
3560            return Err(self.err(format!(
3561                "expected ON after MERGE … USING source, got {:?}",
3562                self.peek()
3563            )));
3564        }
3565        self.advance();
3566        let on = self.parse_expr(0)?;
3567        // One or more WHEN clauses.
3568        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3569        loop {
3570            let is_when_kw = matches!(
3571                self.peek(),
3572                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3573            );
3574            if !is_when_kw {
3575                break;
3576            }
3577            self.advance(); // WHEN
3578            // [NOT] MATCHED
3579            let matched = if matches!(self.peek(), Token::Not) {
3580                self.advance();
3581                crate::ast::MergeMatched::NotMatched
3582            } else {
3583                crate::ast::MergeMatched::Matched
3584            };
3585            let is_matched_kw = matches!(
3586                self.peek(),
3587                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3588            );
3589            if !is_matched_kw {
3590                return Err(self.err(format!(
3591                    "expected MATCHED in WHEN clause, got {:?}",
3592                    self.peek()
3593                )));
3594            }
3595            self.advance();
3596            // Optional AND <expr>
3597            let condition = if matches!(self.peek(), Token::And) {
3598                self.advance();
3599                Some(self.parse_expr(0)?)
3600            } else {
3601                None
3602            };
3603            // THEN
3604            let is_then_kw = matches!(
3605                self.peek(),
3606                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3607            );
3608            if !is_then_kw {
3609                return Err(self.err(format!(
3610                    "expected THEN in WHEN clause, got {:?}",
3611                    self.peek()
3612                )));
3613            }
3614            self.advance();
3615            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3616            let action = match self.peek().clone() {
3617                Token::Insert => {
3618                    self.advance();
3619                    // (cols)
3620                    if !matches!(self.peek(), Token::LParen) {
3621                        return Err(self.err(format!(
3622                            "expected '(' after INSERT in MERGE, got {:?}",
3623                            self.peek()
3624                        )));
3625                    }
3626                    self.advance();
3627                    let mut columns: Vec<String> = Vec::new();
3628                    loop {
3629                        columns.push(self.expect_ident_like()?);
3630                        if matches!(self.peek(), Token::Comma) {
3631                            self.advance();
3632                            continue;
3633                        }
3634                        break;
3635                    }
3636                    if !matches!(self.peek(), Token::RParen) {
3637                        return Err(self.err(format!(
3638                            "expected ')' after INSERT column list, got {:?}",
3639                            self.peek()
3640                        )));
3641                    }
3642                    self.advance();
3643                    // VALUES (...)
3644                    if !matches!(self.peek(), Token::Values) {
3645                        return Err(self.err(format!(
3646                            "expected VALUES in MERGE INSERT, got {:?}",
3647                            self.peek()
3648                        )));
3649                    }
3650                    self.advance();
3651                    if !matches!(self.peek(), Token::LParen) {
3652                        return Err(self.err(format!(
3653                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3654                            self.peek()
3655                        )));
3656                    }
3657                    self.advance();
3658                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3659                    loop {
3660                        values.push(self.parse_expr(0)?);
3661                        if matches!(self.peek(), Token::Comma) {
3662                            self.advance();
3663                            continue;
3664                        }
3665                        break;
3666                    }
3667                    if !matches!(self.peek(), Token::RParen) {
3668                        return Err(self.err(format!(
3669                            "expected ')' after MERGE INSERT values, got {:?}",
3670                            self.peek()
3671                        )));
3672                    }
3673                    self.advance();
3674                    if columns.len() != values.len() {
3675                        return Err(self.err(format!(
3676                            "MERGE INSERT column count ({}) ≠ value count ({})",
3677                            columns.len(),
3678                            values.len()
3679                        )));
3680                    }
3681                    crate::ast::MergeAction::Insert { columns, values }
3682                }
3683                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3684                    self.advance();
3685                    // SET
3686                    let is_set_kw = matches!(
3687                        self.peek(),
3688                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3689                    );
3690                    if !is_set_kw {
3691                        return Err(self.err(format!(
3692                            "expected SET after UPDATE in MERGE, got {:?}",
3693                            self.peek()
3694                        )));
3695                    }
3696                    self.advance();
3697                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3698                    loop {
3699                        let col = self.expect_ident_like()?;
3700                        if !matches!(self.peek(), Token::Eq) {
3701                            return Err(self.err(format!(
3702                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3703                                self.peek()
3704                            )));
3705                        }
3706                        self.advance();
3707                        let expr = self.parse_expr(0)?;
3708                        assignments.push((col, expr));
3709                        if matches!(self.peek(), Token::Comma) {
3710                            self.advance();
3711                            continue;
3712                        }
3713                        break;
3714                    }
3715                    crate::ast::MergeAction::Update { assignments }
3716                }
3717                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3718                    self.advance();
3719                    crate::ast::MergeAction::Delete
3720                }
3721                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3722                    self.advance();
3723                    let is_nothing_kw = matches!(
3724                        self.peek(),
3725                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3726                    );
3727                    if !is_nothing_kw {
3728                        return Err(self.err(format!(
3729                            "expected NOTHING after DO in MERGE clause, got {:?}",
3730                            self.peek()
3731                        )));
3732                    }
3733                    self.advance();
3734                    crate::ast::MergeAction::DoNothing
3735                }
3736                other => {
3737                    return Err(self.err(format!(
3738                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3739                    )));
3740                }
3741            };
3742            clauses.push(crate::ast::MergeWhenClause {
3743                matched,
3744                condition,
3745                action,
3746            });
3747        }
3748        if clauses.is_empty() {
3749            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3750        }
3751        Ok(Statement::Merge(crate::ast::MergeStatement {
3752            target,
3753            target_alias,
3754            source,
3755            source_alias,
3756            on,
3757            clauses,
3758        }))
3759    }
3760
3761    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3762    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3763    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3764    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3765    fn parse_optional_returning(
3766        &mut self,
3767    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3768        let is_returning_kw = matches!(
3769            self.peek(),
3770            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3771        );
3772        if !is_returning_kw {
3773            return Ok(None);
3774        }
3775        self.advance();
3776        let mut items = Vec::new();
3777        loop {
3778            items.push(self.parse_select_item()?);
3779            if matches!(self.peek(), Token::Comma) {
3780                self.advance();
3781                continue;
3782            }
3783            break;
3784        }
3785        Ok(Some(items))
3786    }
3787
3788    /// v6.0.4 — parse the tail of an ALTER statement after the
3789    /// leading `ALTER` keyword has been consumed. Only one form is
3790    /// supported in v6.0.4:
3791    ///
3792    /// ```text
3793    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
3794    /// ```
3795    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
3796        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
3797        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
3798        // exclusion) is accepted by stripping the `ONLY` keyword
3799        // before the table parse.
3800        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
3801        // and the long PG-dump tail are accepted as no-ops.
3802        match self.advance() {
3803            Token::Index => {}
3804            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
3805            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
3806            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
3807            Token::Table => {
3808                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3809                    self.advance();
3810                }
3811                return self.parse_alter_table_after_keyword();
3812            }
3813            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
3814                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3815                    self.advance();
3816                }
3817                return self.parse_alter_table_after_keyword();
3818            }
3819            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
3820            // of the silent-noop tail.
3821            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
3822                return self.parse_alter_sequence_after_keyword();
3823            }
3824            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
3825            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
3826            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
3827            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
3828            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
3829            Token::Ident(s) | Token::QuotedIdent(s)
3830                if matches!(
3831                    s.to_ascii_lowercase().as_str(),
3832                    "view"
3833                        | "function"
3834                        | "type"
3835                        | "domain"
3836                        | "database"
3837                        | "role"
3838                        | "schema"
3839                        | "owner"
3840                        | "default"
3841                        | "extension"
3842                        | "materialized"
3843                        | "policy"
3844                        | "publication"
3845                        | "subscription"
3846                ) =>
3847            {
3848                self.consume_until_statement_boundary();
3849                return Ok(Statement::Empty);
3850            }
3851            other => {
3852                return Err(self.err(format!(
3853                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
3854                     after ALTER, got {other:?}"
3855                )));
3856            }
3857        }
3858        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
3859        // (mailrs migrate-042 ships these). The presence of an
3860        // IF EXISTS makes the subsequent name lookup tolerate
3861        // a missing index — engine returns CommandOk no-op.
3862        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3863            let next = self.tokens.get(self.pos + 1);
3864            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3865                self.advance();
3866                self.advance();
3867                true
3868            } else {
3869                false
3870            }
3871        } else {
3872            false
3873        };
3874        let name = self.expect_ident_like()?;
3875        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
3876        // Detect BEFORE the REBUILD path so the existing REBUILD
3877        // arm stays untouched.
3878        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
3879            self.advance();
3880            if matches!(self.peek(), Token::To) {
3881                self.advance();
3882            } else {
3883                self.expect_keyword_ident("to")?;
3884            }
3885            let new = self.expect_ident_like()?;
3886            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3887                name,
3888                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
3889            }));
3890        }
3891        // REBUILD
3892        self.expect_keyword_ident("rebuild")?;
3893        // Optional: WITH (encoding = <enc>)
3894        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3895            self.advance();
3896            if !matches!(self.peek(), Token::LParen) {
3897                return Err(self.err(format!(
3898                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
3899                    self.peek()
3900                )));
3901            }
3902            self.advance();
3903            self.expect_keyword_ident("encoding")?;
3904            if !matches!(self.peek(), Token::Eq) {
3905                return Err(self.err(format!(
3906                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
3907                    self.peek()
3908                )));
3909            }
3910            self.advance();
3911            let enc_ident = match self.advance() {
3912                Token::Ident(s) | Token::QuotedIdent(s) => s,
3913                other => {
3914                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
3915                }
3916            };
3917            let enc = match enc_ident.to_ascii_lowercase().as_str() {
3918                "f32" => VecEncoding::F32,
3919                "sq8" => VecEncoding::Sq8,
3920                "half" => VecEncoding::F16,
3921                other => {
3922                    return Err(self.err(format!(
3923                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
3924                    )));
3925                }
3926            };
3927            if !matches!(self.peek(), Token::RParen) {
3928                return Err(self.err(format!(
3929                    "expected ')' after encoding value, got {:?}",
3930                    self.peek()
3931                )));
3932            }
3933            self.advance();
3934            Some(enc)
3935        } else {
3936            None
3937        };
3938        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3939            name,
3940            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
3941        }))
3942    }
3943
3944    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
3945    /// only `SET` form currently supported; future v6.7.x can add
3946    /// more SET subjects without changing the dispatch shape.
3947    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
3948    /// subactions. Single-subaction shape stays a 1-element vec.
3949    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
3950        let table_name = self.expect_ident_like()?;
3951        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
3952        loop {
3953            let subaction = self.parse_alter_table_subaction()?;
3954            // ADD COLUMN with inline REFERENCES emits both an
3955            // AddColumn and an AddForeignKey subaction; the
3956            // helper returns 1 or 2 items.
3957            targets.extend(subaction);
3958            if matches!(self.peek(), Token::Comma) {
3959                self.advance();
3960                continue;
3961            }
3962            break;
3963        }
3964        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
3965            name: table_name,
3966            targets,
3967        }))
3968    }
3969
3970    /// Parse one ALTER TABLE subaction. Returns a Vec because
3971    /// inline `REFERENCES` on `ADD COLUMN` produces both an
3972    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
3973    fn parse_alter_table_subaction(
3974        &mut self,
3975    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
3976        match self.peek() {
3977            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
3978                self.advance();
3979                let setting = self.expect_ident_like()?;
3980                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
3981                    return Err(self.err(alloc::format!(
3982                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
3983                    )));
3984                }
3985                if !matches!(self.peek(), Token::Eq) {
3986                    return Err(self.err(alloc::format!(
3987                        "expected '=' after hot_tier_bytes, got {:?}",
3988                        self.peek()
3989                    )));
3990                }
3991                self.advance();
3992                let n = self.expect_u64_literal()?;
3993                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
3994            }
3995            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
3996                self.advance();
3997                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
3998                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
3999                // PRIMARY KEY this way; mysqldump emits both.
4000                // Peek-only dispatch (no advance) — `advance()`
4001                // destructively replaces consumed tokens with Eof,
4002                // so saved-pos restore would land on Eofs.
4003                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
4004                {
4005                    // The next-but-one ident is the constraint
4006                    // name; the one after THAT is the kind.
4007                    let kind_pos = self.pos + 2;
4008                    let kind = self.tokens.get(kind_pos).cloned();
4009                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
4010                    {
4011                        let fk = self.parse_table_level_fk()?;
4012                        return Ok(alloc::vec![
4013                            crate::ast::AlterTableTarget::AddForeignKey(fk)
4014                        ]);
4015                    }
4016                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
4017                    {
4018                        self.advance(); // CONSTRAINT
4019                        let _name = self.expect_ident_like()?;
4020                        self.advance(); // PRIMARY
4021                        self.expect_keyword_ident("key")?;
4022                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4023                        return Ok(alloc::vec![
4024                            crate::ast::AlterTableTarget::AddTableConstraint(
4025                                crate::ast::TableConstraint::PrimaryKey {
4026                                    name: None,
4027                                    columns: cols,
4028                                }
4029                            )
4030                        ]);
4031                    }
4032                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
4033                    {
4034                        self.advance(); // CONSTRAINT
4035                        let _name = self.expect_ident_like()?;
4036                        // v7.22 (mailrs round-13 gap 6) — delegate so
4037                        // the optional `NULLS [NOT] DISTINCT` modifier
4038                        // parses here too (pg_dump emits the ALTER
4039                        // form; semantics enforced by the engine
4040                        // since v7.13).
4041                        let uc = self.parse_table_level_unique()?;
4042                        return Ok(alloc::vec![
4043                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4044                        ]);
4045                    }
4046                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
4047                    {
4048                        self.advance(); // CONSTRAINT
4049                        let _name = self.expect_ident_like()?;
4050                        self.advance(); // CHECK
4051                        if !matches!(self.peek(), Token::LParen) {
4052                            return Err(self.err(alloc::format!(
4053                                "expected '(' after CHECK, got {:?}", self.peek()
4054                            )));
4055                        }
4056                        self.advance();
4057                        let expr = self.parse_expr(0)?;
4058                        if matches!(self.peek(), Token::RParen) {
4059                            self.advance();
4060                        }
4061                        return Ok(alloc::vec![
4062                            crate::ast::AlterTableTarget::AddTableConstraint(
4063                                crate::ast::TableConstraint::Check { name: None, expr }
4064                            )
4065                        ]);
4066                    }
4067                    // Unknown kind — fall through to FK path which
4068                    // produces a descriptive parse error.
4069                }
4070                let is_fk = matches!(
4071                    self.peek(),
4072                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
4073                        || s.eq_ignore_ascii_case("foreign")
4074                );
4075                if is_fk {
4076                    let fk = self.parse_table_level_fk()?;
4077                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
4078                }
4079                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
4080                // (no CONSTRAINT prefix) — same dispatch.
4081                match self.peek().clone() {
4082                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
4083                        self.advance();
4084                        self.expect_keyword_ident("key")?;
4085                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
4086                        return Ok(alloc::vec![
4087                            crate::ast::AlterTableTarget::AddTableConstraint(
4088                                crate::ast::TableConstraint::PrimaryKey {
4089                                    name: None,
4090                                    columns: cols,
4091                                }
4092                            )
4093                        ]);
4094                    }
4095                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4096                        // v7.22 — delegate (NULLS [NOT] DISTINCT).
4097                        let uc = self.parse_table_level_unique()?;
4098                        return Ok(alloc::vec![
4099                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
4100                        ]);
4101                    }
4102                    _ => {}
4103                }
4104                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4105                    self.advance();
4106                }
4107                let mut if_not_exists = false;
4108                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4109                    self.advance();
4110                    if !matches!(self.peek(), Token::Not) {
4111                        return Err(self.err(alloc::format!(
4112                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
4113                            self.peek()
4114                        )));
4115                    }
4116                    self.advance();
4117                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
4118                        return Err(self.err(alloc::format!(
4119                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
4120                            self.peek()
4121                        )));
4122                    }
4123                    self.advance();
4124                    if_not_exists = true;
4125                }
4126                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
4127                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
4128                // returns ColumnDef + an optional inline FK.
4129                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
4130                let col_name = column.name.clone();
4131                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
4132                    column,
4133                    if_not_exists,
4134                }];
4135                if let Some(mut fk) = col_level_fk {
4136                    if fk.columns.is_empty() {
4137                        fk.columns.push(col_name);
4138                    }
4139                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
4140                }
4141                Ok(out)
4142            }
4143            Token::Drop => {
4144                self.advance();
4145                // v7.13.3 — dispatch on the next token. mailrs round-7
4146                // S8 closed DROP COLUMN; round-6 S7 closed
4147                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
4148                // RESTRICT modifiers.
4149                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
4150                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
4151                let subject = match self.peek() {
4152                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
4153                        self.advance();
4154                        "constraint"
4155                    }
4156                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
4157                        self.advance();
4158                        "column"
4159                    }
4160                    // PG-canonical bare `DROP <col>` without COLUMN
4161                    // keyword is also valid; treat any other ident
4162                    // as the column name.
4163                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
4164                    other => {
4165                        return Err(self.err(alloc::format!(
4166                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
4167                        )));
4168                    }
4169                };
4170                let mut if_exists = false;
4171                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4172                    let n1 = self.tokens.get(self.pos + 1);
4173                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4174                        self.advance();
4175                        self.advance();
4176                        if_exists = true;
4177                    }
4178                }
4179                let name = self.expect_ident_like()?;
4180                let mut cascade = false;
4181                if matches!(
4182                    self.peek(),
4183                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
4184                        || s.eq_ignore_ascii_case("restrict")
4185                ) {
4186                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
4187                    {
4188                        cascade = true;
4189                    }
4190                    self.advance();
4191                }
4192                if subject == "constraint" {
4193                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
4194                        name,
4195                        if_exists,
4196                    }])
4197                } else {
4198                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
4199                        column: name,
4200                        if_exists,
4201                        cascade,
4202                    }])
4203                }
4204            }
4205            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
4206                self.advance();
4207                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4208                    self.advance();
4209                }
4210                let col_name = self.expect_ident_like()?;
4211                match self.peek() {
4212                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
4213                        self.advance();
4214                    }
4215                    // v7.14.0 — pg_dump emits BIGSERIAL via
4216                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
4217                    // nextval('seq')` (the sequence is created
4218                    // separately). SPG's BIGSERIAL already uses
4219                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4220                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4221                    // engine no-ops by consuming the tail.
4222                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4223                        // v7.22 (round-13 T2) — `SET DEFAULT
4224                        // nextval('…')` is how pg_dump spells a
4225                        // SERIAL column (plain integer in CREATE
4226                        // TABLE + this ALTER). It used to be
4227                        // swallowed as a no-op, which silently
4228                        // STRIPPED auto-increment from imported
4229                        // schemas — the first post-import INSERT
4230                        // without an explicit id then violated NOT
4231                        // NULL. Lower it to the auto-increment
4232                        // marker instead.
4233                        let is_default_nextval =
4234                            matches!(self.tokens.get(self.pos + 1), Some(Token::Default))
4235                                && matches!(
4236                                    self.tokens.get(self.pos + 2),
4237                                    Some(Token::Ident(f)) if f.eq_ignore_ascii_case("nextval")
4238                                );
4239                        // Capture the nextval target so the engine
4240                        // can guarantee the sequence exists.
4241                        let seq_name = if is_default_nextval {
4242                            self.scan_sequence_name_until_boundary()
4243                        } else {
4244                            self.consume_until_statement_boundary();
4245                            None
4246                        };
4247                        if is_default_nextval {
4248                            return Ok(alloc::vec![
4249                                crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4250                                    column: col_name,
4251                                    seq_name,
4252                                }
4253                            ]);
4254                        }
4255                        // Other SET DEFAULT … / SET NOT NULL forms
4256                        // stay engine no-ops (real defaults arrive
4257                        // inline in CREATE TABLE in every dump;
4258                        // nullability change would need a row scan
4259                        // — deferred).
4260                        return Ok(Vec::new());
4261                    }
4262                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4263                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4264                        self.consume_until_statement_boundary();
4265                        return Ok(Vec::new());
4266                    }
4267                    Token::Drop => {
4268                        // v7.37.43-T4 — same path as the Ident("drop")
4269                        // arm above. `DROP` is unreserved per PG; the
4270                        // lexer emits `Token::Drop` so the publication-
4271                        // DROP path can dispatch on it, but ALTER COLUMN
4272                        // DROP DEFAULT / DROP NOT NULL must also work.
4273                        self.consume_until_statement_boundary();
4274                        return Ok(Vec::new());
4275                    }
4276                    Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4277                        // v7.22 (round-13 T2) — `ALTER COLUMN c ADD
4278                        // GENERATED { ALWAYS | BY DEFAULT } AS
4279                        // IDENTITY ( … )`: pg_dump's spelling for
4280                        // identity columns. Same auto-increment
4281                        // lowering as the nextval default; the
4282                        // sequence options inside the parens are
4283                        // no-ops under SPG's max+1 semantics.
4284                        let is_generated = matches!(
4285                            self.tokens.get(self.pos + 1),
4286                            Some(Token::Ident(g)) if g.eq_ignore_ascii_case("generated")
4287                        );
4288                        if !is_generated {
4289                            return Err(self.err(alloc::format!(
4290                                "expected GENERATED after ALTER COLUMN {col_name} ADD, got {:?}",
4291                                self.tokens.get(self.pos + 1)
4292                            )));
4293                        }
4294                        let seq_name = self.scan_sequence_name_until_boundary();
4295                        return Ok(alloc::vec![
4296                            crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4297                                column: col_name,
4298                                seq_name,
4299                            }
4300                        ]);
4301                    }
4302                    other => {
4303                        return Err(self.err(alloc::format!(
4304                            "expected TYPE / SET / DROP / ADD after ALTER COLUMN <name>, got {other:?}"
4305                        )));
4306                    }
4307                }
4308                let new_type = self.parse_column_type_name()?;
4309                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4310                {
4311                    self.advance();
4312                    Some(self.parse_expr(0)?)
4313                } else {
4314                    None
4315                };
4316                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4317                    column: col_name,
4318                    new_type,
4319                    using,
4320                }])
4321            }
4322            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4323            // PG also supports `RENAME TO new_table` for table-name
4324            // rename; that surface is deferred (pg_dump never emits
4325            // it). If the first post-RENAME ident is `TO`, the user
4326            // is asking for table rename — error with a clear
4327            // message rather than misparsing `TO` as a column name.
4328            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4329                self.advance();
4330                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4331                // table-name rename (mailrs round-10 A.5 — used
4332                // by migrate-042's `RENAME TO email_contacts`).
4333                // `TO` lexes as Token::To.
4334                if matches!(self.peek(), Token::To)
4335                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4336                {
4337                    self.advance();
4338                    let new = self.expect_ident_like()?;
4339                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4340                        new,
4341                    }]);
4342                }
4343                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4344                    self.advance();
4345                }
4346                let old = self.expect_ident_like()?;
4347                // `TO` is a reserved keyword token; accept both
4348                // Token::To and Token::Ident("to") for consistency.
4349                if matches!(self.peek(), Token::To) {
4350                    self.advance();
4351                } else {
4352                    self.expect_keyword_ident("to")?;
4353                }
4354                let new = self.expect_ident_like()?;
4355                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4356                    old,
4357                    new,
4358                }])
4359            }
4360            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4361            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4362            // every data block with these. Real disable semantics —
4363            // not no-op — because reload correctness assumes the
4364            // triggers don't fire (rows already carry their
4365            // computed values from prod).
4366            Token::Ident(s)
4367                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4368            {
4369                let enabled = s.eq_ignore_ascii_case("enable");
4370                self.advance();
4371                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4372                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4373                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4374                // pg_dump output) — anything else falls through to
4375                // the catch-all error below.
4376                // v7.22 (round-13 T3) — mysqldump wraps every data
4377                // section in `/*!40000 ALTER TABLE t DISABLE KEYS */`
4378                // + ENABLE KEYS (a MyISAM index-rebuild hint). SPG
4379                // maintains indexes incrementally — engine no-op.
4380                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("keys")) {
4381                    self.advance();
4382                    return Ok(Vec::new());
4383                }
4384                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4385                    return Err(self.err(alloc::format!(
4386                        "expected TRIGGER after {}, got {:?}",
4387                        if enabled { "ENABLE" } else { "DISABLE" },
4388                        self.peek()
4389                    )));
4390                }
4391                self.advance();
4392                // `ALL` lexes as Token::All (reserved); also
4393                // accept Token::Ident("all") for symmetry.
4394                let which = if matches!(self.peek(), Token::All)
4395                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4396                {
4397                    self.advance();
4398                    crate::ast::TriggerSelector::All
4399                } else {
4400                    let name = self.expect_ident_like()?;
4401                    crate::ast::TriggerSelector::Named(name)
4402                };
4403                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4404                    which,
4405                    enabled,
4406                }])
4407            }
4408            other => Err(self.err(alloc::format!(
4409                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4410            ))),
4411        }
4412    }
4413
4414    /// v7.16.2 — peek for `information_schema.<tbl>` /
4415    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4416    /// three tokens + return a synthetic table name the engine's
4417    /// SELECT path recognises as a virtual view. Returns `None`
4418    /// when the head doesn't look like a meta-qualified name.
4419    /// Used by `parse_table_ref` to bypass the
4420    /// `expect_ident_like` schema-strip for these specific PG
4421    /// meta schemas (mailrs round-10 A.3).
4422    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4423        // Extract the schema name. Must be a plain ident token.
4424        let schema = match self.tokens.get(self.pos) {
4425            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4426            _ => return None,
4427        };
4428        // Dot.
4429        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4430            return None;
4431        }
4432        // The table-side ident may lex as a reserved keyword
4433        // (e.g. `Token::Tables`). Tolerate the common ones via a
4434        // helper that reads the trailing token's underlying name.
4435        let tbl = match self.tokens.get(self.pos + 2)? {
4436            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4437            Token::Tables => "tables".to_string(),
4438            // Other PG meta table names that may collide with
4439            // reserved keywords land here as needed.
4440            _ => return None,
4441        };
4442        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4443        // names so the synthetic name doesn't double-prefix
4444        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4445        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4446            ("__spg_info_", tbl.to_ascii_lowercase())
4447        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4448            let bare = tbl
4449                .to_ascii_lowercase()
4450                .strip_prefix("pg_")
4451                .map(alloc::string::String::from)
4452                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4453            ("__spg_pg_", bare)
4454        } else if schema.eq_ignore_ascii_case("mysql") {
4455            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4456            // (`mysql.user`, `mysql.db`). Same synthetic-name
4457            // shape as pg_catalog.
4458            ("__spg_mysql_", tbl.to_ascii_lowercase())
4459        } else {
4460            return None;
4461        };
4462        self.advance(); // schema
4463        self.advance(); // dot
4464        self.advance(); // tbl
4465        Some(alloc::format!("{prefix}{normalised}"))
4466    }
4467
4468    /// Unqualified PG meta-table names (`FROM pg_extension`, `FROM
4469    /// pg_class`) resolve the same way: PG puts `pg_catalog` at the
4470    /// implicit front of every search_path, so a bare reference to a
4471    /// known catalog table always means the catalog table. Only the
4472    /// names the engine actually synthesises are recognised — any
4473    /// other `pg_*` ident stays a user table (mailrs embed round-12).
4474    fn try_peek_meta_bare(&mut self) -> Option<String> {
4475        const PG_META_TABLES: &[&str] = &[
4476            "pg_attribute",
4477            "pg_class",
4478            "pg_constraint",
4479            "pg_database",
4480            "pg_extension",
4481            "pg_index",
4482            "pg_indexes",
4483            "pg_matviews",
4484            "pg_namespace",
4485            "pg_proc",
4486            "pg_roles",
4487            "pg_settings",
4488            "pg_trigger",
4489            "pg_type",
4490            "pg_user",
4491            "pg_views",
4492        ];
4493        let name = match self.tokens.get(self.pos) {
4494            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4495            _ => return None,
4496        };
4497        // A following dot means this ident is a schema qualifier,
4498        // not a table name — let the qualified path handle it.
4499        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4500            return None;
4501        }
4502        if !PG_META_TABLES.contains(&name.as_str()) {
4503            return None;
4504        }
4505        self.advance();
4506        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4507        Some(alloc::format!("__spg_pg_{bare}"))
4508    }
4509
4510    /// Consume a bare ident if its lowercase matches `kw`, else err.
4511    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4512        match self.advance() {
4513            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4514            other => Err(ParseError {
4515                message: format!("expected {kw:?}, got {other:?}"),
4516                token_pos: self.pos.saturating_sub(1),
4517            }),
4518        }
4519    }
4520
4521    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4522    /// literal (`'foo'`) — same shape used by CREATE USER for the
4523    /// username slot.
4524    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4525        match self.advance() {
4526            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4527            other => Err(ParseError {
4528                message: format!("expected identifier or string, got {other:?}"),
4529                token_pos: self.pos.saturating_sub(1),
4530            }),
4531        }
4532    }
4533
4534    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4535        match self.advance() {
4536            Token::String(s) => Ok(s),
4537            other => Err(ParseError {
4538                message: format!("expected quoted string, got {other:?}"),
4539                token_pos: self.pos.saturating_sub(1),
4540            }),
4541        }
4542    }
4543
4544    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4545        // v7.30.2 (mailrs round-25 ask 2) — derived tables /
4546        // subqueries recurse through here without passing
4547        // parse_expr; share the same nesting budget.
4548        self.enter_nested()?;
4549        let r = self.parse_select_stmt_inner();
4550        self.nest_depth -= 1;
4551        r
4552    }
4553
4554    fn parse_select_stmt_inner(&mut self) -> Result<Statement, ParseError> {
4555        // Caller dispatches on Token::Select; the inner helper handles
4556        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4557        // get a fresh bare-select parse and may not have their own ORDER
4558        // BY / LIMIT.
4559        let mut head = self.parse_bare_select()?;
4560        while matches!(self.peek(), Token::Union) {
4561            self.advance();
4562            let kind = if matches!(self.peek(), Token::All) {
4563                self.advance();
4564                UnionKind::All
4565            } else {
4566                UnionKind::Distinct
4567            };
4568            let peer = self.parse_bare_select()?;
4569            head.unions.push((kind, peer));
4570        }
4571        head.order_by = if matches!(self.peek(), Token::Order) {
4572            self.advance();
4573            if !matches!(self.peek(), Token::By) {
4574                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4575            }
4576            self.advance();
4577            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4578            // `<expr> [ASC|DESC]` items.
4579            let mut keys = Vec::new();
4580            loop {
4581                let expr = self.parse_expr(0)?;
4582                let desc = if matches!(self.peek(), Token::Desc) {
4583                    self.advance();
4584                    true
4585                } else if matches!(self.peek(), Token::Asc) {
4586                    self.advance();
4587                    false
4588                } else {
4589                    false
4590                };
4591                // v7.24 (round-16 A) — explicit NULLS FIRST/LAST.
4592                let nulls_first = self.parse_optional_nulls_placement()?;
4593                keys.push(OrderBy {
4594                    expr,
4595                    desc,
4596                    nulls_first,
4597                });
4598                if matches!(self.peek(), Token::Comma) {
4599                    self.advance();
4600                } else {
4601                    break;
4602                }
4603            }
4604            keys
4605        } else {
4606            Vec::new()
4607        };
4608        head.limit = if matches!(self.peek(), Token::Limit) {
4609            self.advance();
4610            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4611            // PG synonyms for "no limit". Treat both as None
4612            // (no head.limit set) so the engine's existing
4613            // unlimited-result path takes over. Reject was the
4614            // pre-5.1 behaviour and broke pg_dump-flavoured
4615            // tooling that occasionally emits LIMIT NULL.
4616            if self.consume_limit_unbounded_sentinel() {
4617                None
4618            } else {
4619                Some(self.parse_limit_expr("LIMIT")?)
4620            }
4621        } else {
4622            None
4623        };
4624        head.offset = if matches!(self.peek(), Token::Offset) {
4625            self.advance();
4626            // PG also accepts an optional `ROW` / `ROWS` trailer
4627            // after the offset value (`OFFSET 10 ROWS`). The
4628            // FETCH-FIRST branch below relies on the same.
4629            let off = self.parse_limit_expr("OFFSET")?;
4630            self.consume_optional_rows_keyword();
4631            Some(off)
4632        } else {
4633            None
4634        };
4635        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4636        // the SQL-standard alias for LIMIT. PG accepts both
4637        // spellings interchangeably; pg_dump emits FETCH FIRST in
4638        // newer versions. We map it onto `head.limit` so the
4639        // engine path is unified.
4640        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4641        {
4642            self.advance(); // FETCH
4643            // `FIRST` or `NEXT` (both legal per SQL standard).
4644            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4645                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4646            {
4647                self.advance();
4648            }
4649            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4650            // implicit 1 — but we always consume one if present).
4651            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4652                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4653            {
4654                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4655                crate::ast::LimitExpr::Literal(1)
4656            } else {
4657                self.parse_limit_expr("FETCH FIRST")?
4658            };
4659            // Eat `ROW` / `ROWS` if not already consumed above.
4660            self.consume_optional_rows_keyword();
4661            // Optional `ONLY` (the spec form) — or the SQL:2008
4662            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4663            // now honours WITH TIES by extending past the LIMIT
4664            // truncation point through every row that shares the
4665            // last-kept row's ORDER BY key.
4666            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4667                if s.eq_ignore_ascii_case("only"))
4668            {
4669                self.advance();
4670            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4671                if s.eq_ignore_ascii_case("with"))
4672            {
4673                self.advance(); // WITH
4674                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4675                    if s.eq_ignore_ascii_case("ties"))
4676                {
4677                    self.advance();
4678                    head.limit_with_ties = true;
4679                }
4680            }
4681            head.limit = Some(count);
4682        }
4683        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4684        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4685        //       [ OF table_name [, …] ]
4686        //       [ NOWAIT | SKIP LOCKED ]
4687        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4688        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4689        // SELECT already returns a consistent snapshot — so these
4690        // are accept-and-discard: the parser absorbs them so
4691        // mailrs / Rails / Django code paths that emit `SELECT
4692        // … FOR UPDATE` for advisory pessimistic locking load
4693        // without a parser error. The on-disk locking model is
4694        // unchanged; callers that rely on FOR UPDATE for read-
4695        // through-write ordering still get the right answer
4696        // because SPG serialises writes anyway.
4697        self.consume_optional_for_lock_clauses();
4698        Ok(Statement::Select(head))
4699    }
4700
4701    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4702    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4703    /// LOCKED ]` trailers. Each clause is fully accepted and
4704    /// discarded — SPG's single-writer model already satisfies the
4705    /// callers' implicit ordering requirement. Stops at the first
4706    /// token that isn't `FOR`.
4707    fn consume_optional_for_lock_clauses(&mut self) {
4708        while matches!(self.peek(), Token::For) {
4709            self.advance(); // FOR
4710            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4711            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4712            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4713                if s.eq_ignore_ascii_case("no"))
4714            {
4715                self.advance(); // NO
4716                // The next ident should be KEY but be generous;
4717                // anything followed by UPDATE/SHARE is accepted.
4718                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4719                    if s.eq_ignore_ascii_case("key"))
4720                {
4721                    self.advance(); // KEY
4722                }
4723            }
4724            // `KEY` prefix (PG `FOR KEY SHARE`).
4725            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4726                if s.eq_ignore_ascii_case("key"))
4727            {
4728                self.advance(); // KEY
4729            }
4730            // Lock-strength keyword: UPDATE / SHARE. Required, but
4731            // we're lenient — an unexpected token here just bails
4732            // (we already consumed FOR; caller's downstream
4733            // dispatch will error if anything actually depends on
4734            // the trailing tokens).
4735            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4736                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4737            {
4738                self.advance();
4739            } else {
4740                // FOR by itself (or `FOR KEY` with nothing after) —
4741                // give up on the lock-clause path. We've already
4742                // advanced past FOR; further attempts to parse
4743                // here would clobber state.
4744                return;
4745            }
4746            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4747            // joining and locking only a subset of tables.
4748            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4749                if s.eq_ignore_ascii_case("of"))
4750            {
4751                self.advance(); // OF
4752                #[allow(clippy::while_let_loop)]
4753                loop {
4754                    match self.peek() {
4755                        Token::Ident(_) | Token::QuotedIdent(_) => {
4756                            self.advance();
4757                            // Optional schema-qualified `schema.table`.
4758                            if matches!(self.peek(), Token::Dot) {
4759                                self.advance();
4760                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4761                                    self.advance();
4762                                }
4763                            }
4764                        }
4765                        _ => break,
4766                    }
4767                    if matches!(self.peek(), Token::Comma) {
4768                        self.advance();
4769                    } else {
4770                        break;
4771                    }
4772                }
4773            }
4774            // Optional `NOWAIT` | `SKIP LOCKED`.
4775            match self.peek().clone() {
4776                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4777                    self.advance();
4778                }
4779                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4780                    self.advance(); // SKIP
4781                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4782                        if s.eq_ignore_ascii_case("locked"))
4783                    {
4784                        self.advance(); // LOCKED
4785                    }
4786                }
4787                _ => {}
4788            }
4789            // Loop: PG allows multiple FOR clauses chained.
4790        }
4791    }
4792
4793    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4794    /// Bind value gets resolved during prepared-statement Execute;
4795    /// the Pratt expression parser would over-accept here (e.g.
4796    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4797    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4798    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4799    /// when one was consumed; caller skips the regular
4800    /// limit-value parse and leaves `head.limit` at None.
4801    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4802        if matches!(self.peek(), Token::Null) {
4803            self.advance();
4804            return true;
4805        }
4806        if matches!(self.peek(), Token::All) {
4807            self.advance();
4808            return true;
4809        }
4810        false
4811    }
4812
4813    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4814    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4815    /// SQL-standard shape. No-op when missing.
4816    fn consume_optional_rows_keyword(&mut self) {
4817        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4818            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4819        {
4820            self.advance();
4821        }
4822    }
4823
4824    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4825        match self.advance() {
4826            Token::Integer(n) if n >= 0 => u32::try_from(n)
4827                .map(crate::ast::LimitExpr::Literal)
4828                .map_err(|_| ParseError {
4829                    message: alloc::format!("{label} value too large: {n}"),
4830                    token_pos: self.pos.saturating_sub(1),
4831                }),
4832            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4833            other => Err(ParseError {
4834                message: alloc::format!(
4835                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4836                ),
4837                token_pos: self.pos.saturating_sub(1),
4838            }),
4839        }
4840    }
4841
4842    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4843    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4844    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4845    /// `parse_select_stmt` is responsible for filling those in.
4846    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4847        if !matches!(self.peek(), Token::Select) {
4848            return Err(self.err(format!(
4849                "expected SELECT to start a query block, got {:?}",
4850                self.peek()
4851            )));
4852        }
4853        self.advance();
4854        let distinct = if matches!(self.peek(), Token::Distinct) {
4855            self.advance();
4856            true
4857        } else {
4858            false
4859        };
4860        let items = self.parse_select_list()?;
4861        let from = if matches!(self.peek(), Token::From) {
4862            self.advance();
4863            Some(self.parse_from_clause()?)
4864        } else {
4865            None
4866        };
4867        let where_ = if matches!(self.peek(), Token::Where) {
4868            self.advance();
4869            Some(self.parse_expr(0)?)
4870        } else {
4871            None
4872        };
4873        let mut group_by_all = false;
4874        let group_by = if matches!(self.peek(), Token::Group) {
4875            self.advance();
4876            if !matches!(self.peek(), Token::By) {
4877                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4878            }
4879            self.advance();
4880            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4881            // every non-aggregate SELECT-list item later.
4882            if matches!(self.peek(), Token::All) {
4883                self.advance();
4884                group_by_all = true;
4885                None
4886            } else {
4887                let mut groups = Vec::new();
4888                loop {
4889                    groups.push(self.parse_expr(0)?);
4890                    if matches!(self.peek(), Token::Comma) {
4891                        self.advance();
4892                    } else {
4893                        break;
4894                    }
4895                }
4896                Some(groups)
4897            }
4898        } else {
4899            None
4900        };
4901        let having = if matches!(self.peek(), Token::Having) {
4902            self.advance();
4903            Some(self.parse_expr(0)?)
4904        } else {
4905            None
4906        };
4907        Ok(SelectStatement {
4908            ctes: Vec::new(),
4909            distinct,
4910            items,
4911            from,
4912            where_,
4913            group_by,
4914            group_by_all,
4915            having,
4916            unions: Vec::new(),
4917            order_by: Vec::new(),
4918            limit: None,
4919            offset: None,
4920            limit_with_ties: false,
4921        })
4922    }
4923
4924    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4925        // Caller already consumed CREATE; we're sitting on TABLE.
4926        debug_assert!(matches!(self.peek(), Token::Table));
4927        self.advance();
4928        let if_not_exists = self.consume_if_not_exists();
4929        let name = self.expect_ident_like()?;
4930        // v7.37.6-B — `CREATE TABLE c PARTITION OF parent <bounds>`
4931        // child shape has no column list; the child inherits its
4932        // columns from the parent at engine-DDL time. Detect it
4933        // before the `(` requirement below.
4934        if matches!(self.peek(), Token::Partition)
4935            && Self::tokens_match_ident_ci(self.tokens.get(self.pos + 1), "of")
4936        {
4937            self.advance(); // PARTITION
4938            self.advance(); // of
4939            let partition_of = self.parse_partition_of_tail()?;
4940            return Ok(Statement::CreateTable(CreateTableStatement {
4941                name,
4942                columns: Vec::new(),
4943                if_not_exists,
4944                foreign_keys: Vec::new(),
4945                table_constraints: Vec::new(),
4946                partition_by: None,
4947                partition_of: Some(partition_of),
4948            }));
4949        }
4950        if !matches!(self.peek(), Token::LParen) {
4951            return Err(self.err(format!(
4952                "expected '(' after table name, got {:?}",
4953                self.peek()
4954            )));
4955        }
4956        self.advance();
4957        let mut columns = Vec::new();
4958        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
4959        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
4960        loop {
4961            // v7.6.0 / v7.9.18 — distinguish table-level constraint
4962            // clauses from column definitions. Constraints start
4963            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
4964            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
4965            // a column.
4966            if self.peek_table_level_pk_start() {
4967                table_constraints.push(self.parse_table_level_primary_key()?);
4968            } else if self.peek_table_level_unique_start() {
4969                table_constraints.push(self.parse_table_level_unique()?);
4970            } else if self.peek_table_level_check_start() {
4971                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
4972                table_constraints.push(self.parse_table_level_check()?);
4973            } else if self.peek_mysql_inline_key_start() {
4974                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
4975                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
4976                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
4977                // inside the column list. Skip name + paren list;
4978                // for UNIQUE KEY, register as a UC.
4979                if let Some(uc) = self.parse_mysql_inline_key()? {
4980                    table_constraints.push(uc);
4981                }
4982            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
4983                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
4984                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
4985                // CHECK is named, and the named-CONSTRAINT arm used
4986                // to accept FOREIGN KEY only. The name is accepted
4987                // and discarded — same handling as every other SPG
4988                // constraint name.
4989                self.advance(); // CONSTRAINT
4990                let _name = self.expect_ident_like()?;
4991                table_constraints.push(match kind {
4992                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
4993                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
4994                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
4995                });
4996            } else if self.peek_constraint_or_fk_start() {
4997                foreign_keys.push(self.parse_table_level_fk()?);
4998            } else {
4999                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
5000                // v7.13.0 — fold inline UNIQUE / CHECK column
5001                // constraints into table-level entries so the
5002                // engine path stays uniform.
5003                if col.is_unique {
5004                    table_constraints.push(crate::ast::TableConstraint::Unique {
5005                        name: None,
5006                        columns: alloc::vec![col.name.clone()],
5007                        nulls_not_distinct: false,
5008                    });
5009                }
5010                if let Some(check_expr) = col.check.clone() {
5011                    table_constraints.push(crate::ast::TableConstraint::Check {
5012                        name: None,
5013                        expr: check_expr,
5014                    });
5015                }
5016                columns.push(col);
5017                if let Some(fk) = col_level_fk {
5018                    foreign_keys.push(fk);
5019                }
5020            }
5021            match self.peek() {
5022                Token::Comma => {
5023                    self.advance();
5024                }
5025                Token::RParen => {
5026                    self.advance();
5027                    break;
5028                }
5029                other => {
5030                    return Err(
5031                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
5032                    );
5033                }
5034            }
5035        }
5036        if columns.is_empty() {
5037            return Err(self.err("CREATE TABLE requires at least one column".into()));
5038        }
5039        // v7.14.0 — consume MySQL/MariaDB table options after the
5040        // closing `)`. mysqldump emits things like
5041        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
5042        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
5043        // SPG accepts all forms as no-ops (each option is
5044        // `<ident> [=] <ident-or-string>` separated by whitespace).
5045        self.consume_mysql_table_options();
5046        // v7.37.6-B — declarative-partition-parent suffix
5047        // (`PARTITION BY RANGE (key_col)`) sits after the column
5048        // list + MySQL table-options. v7.37.6-B only accepts RANGE
5049        // and locks the key column at one ident; the engine then
5050        // verifies the column type is TIMESTAMPTZ.
5051        let partition_by = if matches!(self.peek(), Token::Partition) {
5052            self.advance(); // PARTITION
5053            if !matches!(self.peek(), Token::By) {
5054                return Err(self.err(format!(
5055                    "expected BY after PARTITION, got {:?}",
5056                    self.peek()
5057                )));
5058            }
5059            self.advance();
5060            Some(self.parse_partition_by_tail()?)
5061        } else {
5062            None
5063        };
5064        Ok(Statement::CreateTable(CreateTableStatement {
5065            name,
5066            columns,
5067            if_not_exists,
5068            foreign_keys,
5069            table_constraints,
5070            partition_by,
5071            partition_of: None,
5072        }))
5073    }
5074
5075    /// v7.37.6-B — case-insensitive ident match helper for the
5076    /// `PARTITION OF` / `MINVALUE` / `MAXVALUE` keywords. They lex
5077    /// as `Token::Ident("of"/"minvalue"/"maxvalue")` because we
5078    /// didn't burn a global keyword slot for each (see the
5079    /// `Token::Partition` doc-comment in `lexer.rs`).
5080    fn tokens_match_ident_ci(t: Option<&Token>, want: &str) -> bool {
5081        matches!(t, Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case(want))
5082    }
5083
5084    /// v7.37.6-B — after `PARTITION BY`, expect `RANGE (key_col [, ...])`.
5085    fn parse_partition_by_tail(&mut self) -> Result<crate::ast::PartitionBySpec, ParseError> {
5086        use crate::ast::{PartitionBySpec, PartitionKindAst};
5087        let kind = match self.peek() {
5088            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("range") => {
5089                self.advance();
5090                PartitionKindAst::Range
5091            }
5092            other => {
5093                return Err(self.err(format!(
5094                    "PARTITION BY: only RANGE is supported at v7.37.6-B, got {other:?}"
5095                )));
5096            }
5097        };
5098        if !matches!(self.peek(), Token::LParen) {
5099            return Err(self.err(format!(
5100                "expected '(' after PARTITION BY RANGE, got {:?}",
5101                self.peek()
5102            )));
5103        }
5104        self.advance();
5105        let mut key_columns = Vec::new();
5106        loop {
5107            key_columns.push(self.expect_ident_like()?);
5108            match self.peek() {
5109                Token::Comma => {
5110                    self.advance();
5111                }
5112                Token::RParen => {
5113                    self.advance();
5114                    break;
5115                }
5116                other => {
5117                    return Err(self.err(format!(
5118                        "expected ',' or ')' in PARTITION BY key list, got {other:?}"
5119                    )));
5120                }
5121            }
5122        }
5123        if key_columns.is_empty() {
5124            return Err(self.err("PARTITION BY RANGE requires at least one key column".to_string()));
5125        }
5126        Ok(PartitionBySpec { kind, key_columns })
5127    }
5128
5129    /// v7.37.6-B — after `PARTITION OF`, expect
5130    ///   <parent> FOR VALUES FROM ( <expr> ) TO ( <expr> )
5131    /// or
5132    ///   <parent> DEFAULT
5133    fn parse_partition_of_tail(&mut self) -> Result<crate::ast::PartitionOfSpec, ParseError> {
5134        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
5135        let parent_name = self.expect_ident_like()?;
5136        // v7.37.6-B rejects an explicit column list — the child
5137        // inherits from the parent. mailrs round-7 taught us that
5138        // CREATE TABLE-side schema reconciliation hides drift, so
5139        // we surface this as a parse error rather than silently
5140        // ignoring user columns.
5141        if matches!(self.peek(), Token::LParen) {
5142            return Err(self.err(
5143                "CREATE TABLE … PARTITION OF parent: explicit column list not supported \
5144                 at v7.37.6-B; the child inherits its columns from the parent"
5145                    .to_string(),
5146            ));
5147        }
5148        let bounds = match self.peek() {
5149            Token::Default => {
5150                self.advance();
5151                PartitionOfBoundsAst::Default
5152            }
5153            Token::For => {
5154                self.advance();
5155                if !matches!(self.peek(), Token::Values) {
5156                    return Err(
5157                        self.err(format!("expected VALUES after FOR, got {:?}", self.peek()))
5158                    );
5159                }
5160                self.advance();
5161                if !matches!(self.peek(), Token::From) {
5162                    return Err(self.err(format!(
5163                        "expected FROM after FOR VALUES, got {:?}",
5164                        self.peek()
5165                    )));
5166                }
5167                self.advance();
5168                let lower = Box::new(self.parse_partition_bound_expr()?);
5169                if !matches!(self.peek(), Token::To) {
5170                    return Err(self.err(format!(
5171                        "expected TO after FROM (...), got {:?}",
5172                        self.peek()
5173                    )));
5174                }
5175                self.advance();
5176                let upper = Box::new(self.parse_partition_bound_expr()?);
5177                PartitionOfBoundsAst::Range { lower, upper }
5178            }
5179            other => {
5180                return Err(self.err(format!(
5181                    "expected FOR VALUES or DEFAULT after PARTITION OF parent, got {other:?}"
5182                )));
5183            }
5184        };
5185        Ok(PartitionOfSpec {
5186            parent_name,
5187            bounds,
5188        })
5189    }
5190
5191    /// v7.37.6-B — a single `( <expr> )` bound. `MINVALUE` /
5192    /// `MAXVALUE` lex as Ident; rewrite them into FunctionCall
5193    /// markers (no-arg builtins) so the engine resolves them
5194    /// against [`spg_storage::PartitionBound::{MinValue, MaxValue}`].
5195    fn parse_partition_bound_expr(&mut self) -> Result<crate::ast::Expr, ParseError> {
5196        if !matches!(self.peek(), Token::LParen) {
5197            return Err(self.err(format!(
5198                "expected '(' before partition bound, got {:?}",
5199                self.peek()
5200            )));
5201        }
5202        self.advance();
5203        let expr = match self.peek() {
5204            Token::Ident(s) | Token::QuotedIdent(s)
5205                if s.eq_ignore_ascii_case("minvalue") || s.eq_ignore_ascii_case("maxvalue") =>
5206            {
5207                let name = s.to_ascii_uppercase();
5208                self.advance();
5209                crate::ast::Expr::FunctionCall {
5210                    name,
5211                    args: Vec::new(),
5212                }
5213            }
5214            _ => self.parse_expr(0)?,
5215        };
5216        if !matches!(self.peek(), Token::RParen) {
5217            return Err(self.err(format!(
5218                "expected ')' after partition bound, got {:?}",
5219                self.peek()
5220            )));
5221        }
5222        self.advance();
5223        Ok(expr)
5224    }
5225
5226    /// v7.14.0 — true when the next tokens look like an inline
5227    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
5228    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
5229    /// — each followed by an optional name + `(...)`. Critical:
5230    /// a column NAMED `key` / `index` (PG accepts as ident) must
5231    /// NOT be mistaken for the KEY constraint shape. We disambig
5232    /// by requiring the keyword to be followed by either `(` or
5233    /// `<ident> (`.
5234    fn peek_mysql_inline_key_start(&self) -> bool {
5235        let cur = self.peek();
5236        // Shapes:
5237        //   KEY (cols)
5238        //   KEY name (cols)
5239        //   INDEX (cols)
5240        //   INDEX name (cols)
5241        //   UNIQUE KEY [name] (cols)
5242        //   UNIQUE INDEX [name] (cols)
5243        //   FULLTEXT [KEY|INDEX] [name] (cols)
5244        //   SPATIAL [KEY|INDEX] [name] (cols)
5245        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
5246            // tokens at skip = the position AFTER the index-form
5247            // keywords (KEY/INDEX) have been consumed.
5248            match self.tokens.get(skip) {
5249                Some(Token::LParen) => true,
5250                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
5251                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
5252                }
5253                _ => false,
5254            }
5255        };
5256        // `INDEX` lexes as Token::Index (reserved), not as
5257        // Token::Ident("index"). Both shapes count as a KEY/INDEX
5258        // start; the peek helper below handles either.
5259        let is_key_or_index_tok = |t: &Token| -> bool {
5260            matches!(t, Token::Index)
5261                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
5262        };
5263        match cur {
5264            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
5265            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5266                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
5267            }
5268            Token::Ident(s)
5269                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
5270            {
5271                let nxt = self.tokens.get(self.pos + 1);
5272                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
5273                    self.pos + 2
5274                } else {
5275                    self.pos + 1
5276                };
5277                after_keyword_followed_by_paren_or_ident_paren(after_after)
5278            }
5279            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
5280                let nxt = self.tokens.get(self.pos + 1);
5281                if !nxt.is_some_and(is_key_or_index_tok) {
5282                    return false;
5283                }
5284                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
5285            }
5286            _ => false,
5287        }
5288    }
5289
5290    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
5291    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
5292    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
5293    /// returns Some(TableConstraint::Index) so the engine builds
5294    /// a real BTree index on the leading column (mysqldump
5295    /// `KEY idx_posts_author (author_id)` shape).
5296    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
5297    /// (the storage layer has no matching AM).
5298    fn parse_mysql_inline_key(
5299        &mut self,
5300    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
5301        // Detect UNIQUE prefix.
5302        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
5303        {
5304            self.advance();
5305            true
5306        } else {
5307            false
5308        };
5309        // Consume FULLTEXT / SPATIAL prefix and record which one
5310        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
5311        // dedicated TableConstraint variant so the engine can
5312        // build a tsvector-GIN; SPATIAL still has no matching
5313        // AM, so it falls back to accept-as-no-op.
5314        let mut is_fulltext = false;
5315        let mut is_spatial = false;
5316        if let Token::Ident(s) = self.peek().clone() {
5317            if s.eq_ignore_ascii_case("fulltext") {
5318                self.advance();
5319                is_fulltext = true;
5320            } else if s.eq_ignore_ascii_case("spatial") {
5321                self.advance();
5322                is_spatial = true;
5323            }
5324        }
5325        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
5326        // (reserved); accept either token shape.
5327        match self.peek() {
5328            Token::Index => {
5329                self.advance();
5330            }
5331            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
5332                self.advance();
5333            }
5334            other => {
5335                return Err(self.err(alloc::format!(
5336                    "expected KEY/INDEX in inline index declaration, got {other:?}"
5337                )));
5338            }
5339        }
5340        // Optional index name (an ident before the `(`).
5341        // v7.15.0 — capture the name when present so the engine
5342        // builds the secondary index under the user's chosen
5343        // name (matches mysqldump's `KEY idx_x (col)` shape).
5344        let mut idx_name: Option<String> = None;
5345        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
5346            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
5347        {
5348            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
5349                idx_name = Some(s);
5350            }
5351        }
5352        // Optional `USING BTREE` / `USING HASH` (MySQL).
5353        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5354            self.advance();
5355            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5356                self.advance();
5357            }
5358        }
5359        // Required column list `(col [, col]*)`.
5360        if !matches!(self.peek(), Token::LParen) {
5361            return Err(self.err(alloc::format!(
5362                "expected '(' in inline KEY/INDEX, got {:?}",
5363                self.peek()
5364            )));
5365        }
5366        self.advance();
5367        let mut cols: Vec<String> = Vec::new();
5368        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5369            self.advance();
5370            cols.push(s);
5371            // Skip optional `(length)` per-column prefix.
5372            if matches!(self.peek(), Token::LParen) {
5373                let mut depth = 1usize;
5374                self.advance();
5375                while depth > 0 {
5376                    match self.peek() {
5377                        Token::LParen => depth += 1,
5378                        Token::RParen => depth -= 1,
5379                        Token::Eof => break,
5380                        _ => {}
5381                    }
5382                    self.advance();
5383                }
5384            }
5385            // Skip optional ASC / DESC.
5386            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5387                || matches!(self.peek(), Token::Asc | Token::Desc)
5388            {
5389                self.advance();
5390            }
5391            if matches!(self.peek(), Token::Comma) {
5392                self.advance();
5393                continue;
5394            }
5395            break;
5396        }
5397        if matches!(self.peek(), Token::RParen) {
5398            self.advance();
5399        }
5400        // Trailing options on the inline index — comment / etc.
5401        // Skip until comma or `)`.
5402        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5403            self.advance();
5404        }
5405        if cols.is_empty() {
5406            return Ok(None);
5407        }
5408        if is_unique {
5409            // Carry the captured idx_name on UNIQUE too so future
5410            // engine work can name the underlying BTree
5411            // accordingly; today the unique-constraint installer
5412            // synthesises the name itself, but Display round-trip
5413            // benefits from preserving it.
5414            Ok(Some(crate::ast::TableConstraint::Unique {
5415                name: idx_name,
5416                columns: cols,
5417                nulls_not_distinct: false,
5418            }))
5419        } else if is_fulltext {
5420            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5421            // routes through `TableConstraint::FulltextIndex`;
5422            // the engine builds a tsvector-GIN over each named
5423            // column so MATCH AGAINST gets a real inverted
5424            // index instead of a silently-dropped declaration.
5425            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5426                name: idx_name,
5427                columns: cols,
5428            }))
5429        } else if is_spatial {
5430            // SPG has no native SPATIAL AM. Accept-as-no-op
5431            // (declaration is parsed, but no index is built).
5432            Ok(None)
5433        } else {
5434            // v7.15.0 — plain KEY / INDEX builds a real BTree
5435            // secondary index.
5436            Ok(Some(crate::ast::TableConstraint::Index {
5437                name: idx_name,
5438                columns: cols,
5439            }))
5440        }
5441    }
5442
5443    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5444    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5445    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5446    /// (in any order, separated by whitespace).
5447    fn consume_mysql_table_options(&mut self) {
5448        loop {
5449            // Heuristic: a table option is an ident (or `DEFAULT`
5450            // reserved keyword) followed by `=` and an
5451            // ident / string / integer.
5452            let name_lc = match self.peek().clone() {
5453                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5454                Token::Default => alloc::string::String::from("default"),
5455                _ => break,
5456            };
5457            let known = matches!(
5458                name_lc.as_str(),
5459                "engine"
5460                    | "default"
5461                    | "charset"
5462                    | "collate"
5463                    | "auto_increment"
5464                    | "row_format"
5465                    | "comment"
5466                    | "pack_keys"
5467                    | "stats_persistent"
5468                    | "stats_auto_recalc"
5469                    | "stats_sample_pages"
5470                    | "key_block_size"
5471                    | "tablespace"
5472                    | "min_rows"
5473                    | "max_rows"
5474                    | "checksum"
5475                    | "delay_key_write"
5476                    | "insert_method"
5477                    | "data"
5478                    | "index"
5479                    | "encryption"
5480                    | "compression"
5481            );
5482            if !known {
5483                break;
5484            }
5485            self.advance(); // option name
5486            // `DEFAULT` optional prefix is followed by `CHARSET` /
5487            // `COLLATE`; consume the next ident too.
5488            if name_lc == "default" {
5489                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5490                    self.advance();
5491                }
5492            }
5493            if matches!(self.peek(), Token::Eq) {
5494                self.advance();
5495            }
5496            match self.peek() {
5497                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5498                    self.advance();
5499                }
5500                _ => {}
5501            }
5502        }
5503    }
5504
5505    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5506    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5507    /// sure (otherwise a column literally named `primary` would
5508    /// be mistaken).
5509    fn peek_table_level_pk_start(&self) -> bool {
5510        let cur = self.peek();
5511        let nxt = self.tokens.get(self.pos + 1);
5512        let nxt2 = self.tokens.get(self.pos + 2);
5513        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5514        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5515        let is_lparen = matches!(nxt2, Some(Token::LParen));
5516        is_primary && is_key && is_lparen
5517    }
5518
5519    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5520    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5521    /// (mailrs round-5 G10).
5522    fn peek_table_level_unique_start(&self) -> bool {
5523        let cur = self.peek();
5524        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5525        if !is_unique {
5526            return false;
5527        }
5528        let n1 = self.tokens.get(self.pos + 1);
5529        // Plain `UNIQUE (…)`.
5530        if matches!(n1, Some(Token::LParen)) {
5531            return true;
5532        }
5533        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5534        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5535        if !is_nulls {
5536            return false;
5537        }
5538        let n2 = self.tokens.get(self.pos + 2);
5539        let n3 = self.tokens.get(self.pos + 3);
5540        let n4 = self.tokens.get(self.pos + 4);
5541        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5542        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5543            return true;
5544        }
5545        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5546        if matches!(n2, Some(Token::Not))
5547            && matches!(n3, Some(Token::Distinct))
5548            && matches!(n4, Some(Token::LParen))
5549        {
5550            return true;
5551        }
5552        false
5553    }
5554
5555    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5556        self.advance(); // PRIMARY
5557        self.advance(); // KEY
5558        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5559        Ok(crate::ast::TableConstraint::PrimaryKey {
5560            name: None,
5561            columns,
5562        })
5563    }
5564
5565    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5566        self.advance(); // UNIQUE
5567        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5568        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5569        // is `NULLS DISTINCT` per the SQL standard.
5570        let mut nulls_not_distinct = false;
5571        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5572            let n1 = self.tokens.get(self.pos + 1);
5573            let n2 = self.tokens.get(self.pos + 2);
5574            let is_not = matches!(n1, Some(Token::Not));
5575            let is_distinct = matches!(n2, Some(Token::Distinct));
5576            if is_not && is_distinct {
5577                self.advance(); // NULLS
5578                self.advance(); // NOT
5579                self.advance(); // DISTINCT
5580                nulls_not_distinct = true;
5581            } else if matches!(n1, Some(Token::Distinct)) {
5582                self.advance(); // NULLS
5583                self.advance(); // DISTINCT
5584            }
5585        }
5586        let columns = self.parse_paren_ident_list("UNIQUE")?;
5587        Ok(crate::ast::TableConstraint::Unique {
5588            name: None,
5589            columns,
5590            nulls_not_distinct,
5591        })
5592    }
5593
5594    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5595    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5596    /// expression.
5597    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5598        self.advance(); // CHECK
5599        if !matches!(self.peek(), Token::LParen) {
5600            return Err(self.err(alloc::format!(
5601                "expected '(' after CHECK, got {:?}",
5602                self.peek()
5603            )));
5604        }
5605        self.advance();
5606        let expr = self.parse_expr(0)?;
5607        if !matches!(self.peek(), Token::RParen) {
5608            return Err(self.err(alloc::format!(
5609                "expected ')' to close CHECK predicate, got {:?}",
5610                self.peek()
5611            )));
5612        }
5613        self.advance();
5614        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5615    }
5616
5617    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5618    fn peek_table_level_check_start(&self) -> bool {
5619        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5620    }
5621
5622    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5623    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5624    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5625    /// own CONSTRAINT prefix).
5626    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5627        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5628            return None;
5629        }
5630        // tokens[pos+1] is the constraint name (any ident-like);
5631        // tokens[pos+2] is the kind keyword.
5632        match self.tokens.get(self.pos + 2) {
5633            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5634                Some(NamedTableConstraintKind::Check)
5635            }
5636            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5637                Some(NamedTableConstraintKind::Unique)
5638            }
5639            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5640                Some(NamedTableConstraintKind::PrimaryKey)
5641            }
5642            _ => None,
5643        }
5644    }
5645
5646    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5647        if !matches!(self.peek(), Token::LParen) {
5648            return Err(self.err(alloc::format!(
5649                "expected '(' after {ctx}, got {:?}",
5650                self.peek()
5651            )));
5652        }
5653        self.advance();
5654        let mut out = Vec::new();
5655        loop {
5656            out.push(self.expect_ident_like()?);
5657            match self.peek() {
5658                Token::Comma => {
5659                    self.advance();
5660                }
5661                Token::RParen => {
5662                    self.advance();
5663                    break;
5664                }
5665                other => {
5666                    return Err(self.err(alloc::format!(
5667                        "expected ',' or ')' in {ctx} list, got {other:?}"
5668                    )));
5669                }
5670            }
5671        }
5672        if out.is_empty() {
5673            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5674        }
5675        Ok(out)
5676    }
5677
5678    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5679    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5680    /// table-level FK; a column def never starts with either keyword
5681    /// (column names are not in this reserved set).
5682    fn peek_constraint_or_fk_start(&self) -> bool {
5683        let is_constraint_kw = matches!(
5684            self.peek(),
5685            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5686        );
5687        let is_foreign_kw = matches!(
5688            self.peek(),
5689            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5690        );
5691        is_constraint_kw || is_foreign_kw
5692    }
5693
5694    /// v7.6.0 — parse a table-level FK clause:
5695    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5696    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5697    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5698        let mut name: Option<String> = None;
5699        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5700            self.advance();
5701            name = Some(self.expect_ident_like()?);
5702        }
5703        // `FOREIGN`
5704        match self.advance() {
5705            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5706            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5707        }
5708        // `KEY`
5709        match self.advance() {
5710            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5711            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5712        }
5713        // `(col, col, ...)`
5714        if !matches!(self.peek(), Token::LParen) {
5715            return Err(self.err(format!(
5716                "expected '(' after FOREIGN KEY, got {:?}",
5717                self.peek()
5718            )));
5719        }
5720        self.advance();
5721        let mut columns = Vec::new();
5722        loop {
5723            columns.push(self.expect_ident_like()?);
5724            match self.peek() {
5725                Token::Comma => {
5726                    self.advance();
5727                }
5728                Token::RParen => {
5729                    self.advance();
5730                    break;
5731                }
5732                other => {
5733                    return Err(self.err(format!(
5734                        "expected ',' or ')' in FK column list, got {other:?}"
5735                    )));
5736                }
5737            }
5738        }
5739        if columns.is_empty() {
5740            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5741        }
5742        let (parent_table, parent_columns, on_delete, on_update) =
5743            self.parse_references_tail(columns.len())?;
5744        Ok(ForeignKeyConstraint {
5745            name,
5746            columns,
5747            parent_table,
5748            parent_columns,
5749            on_delete,
5750            on_update,
5751        })
5752    }
5753
5754    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5755    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5756    /// the local column count, used to default the parent column
5757    /// list when omitted (SQL spec: parent's PK is implied).
5758    fn parse_references_tail(
5759        &mut self,
5760        expected_arity: usize,
5761    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5762        match self.advance() {
5763            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5764            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5765        }
5766        let parent_table = self.expect_ident_like()?;
5767        let mut parent_columns: Vec<String> = Vec::new();
5768        if matches!(self.peek(), Token::LParen) {
5769            self.advance();
5770            loop {
5771                parent_columns.push(self.expect_ident_like()?);
5772                match self.peek() {
5773                    Token::Comma => {
5774                        self.advance();
5775                    }
5776                    Token::RParen => {
5777                        self.advance();
5778                        break;
5779                    }
5780                    other => {
5781                        return Err(self.err(format!(
5782                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5783                        )));
5784                    }
5785                }
5786            }
5787        }
5788        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5789            return Err(self.err(format!(
5790                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5791                expected_arity,
5792                parent_columns.len()
5793            )));
5794        }
5795        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5796        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5797        // <action>` / `ON UPDATE <action>` in either order. PG /
5798        // pg_dump emits the timing clause AFTER the ON clauses
5799        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5800        // but the SQL spec allows either order. We loop over
5801        // every possible trailer and dispatch on the next token,
5802        // stopping when nothing matches. Phase 3.1 changes the
5803        // bare DEFERRABLE form from hard-error to accept-as-
5804        // immediate; SPG is single-writer with no deferred-
5805        // constraint window so the runtime semantics are always
5806        // immediate even when INITIALLY DEFERRED is requested.
5807        let mut on_delete = FkAction::Restrict;
5808        let mut on_update = FkAction::Restrict;
5809        let mut seen_on_delete = false;
5810        let mut seen_on_update = false;
5811        loop {
5812            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5813            let before = self.pos;
5814            self.consume_optional_deferrable_clauses()?;
5815            if self.pos != before {
5816                continue;
5817            }
5818            // ON DELETE / ON UPDATE.
5819            if !matches!(self.peek(), Token::On) {
5820                break;
5821            }
5822            self.advance();
5823            let which = self.advance();
5824            let action = self.parse_fk_action()?;
5825            match which {
5826                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5827                    if seen_on_delete {
5828                        return Err(self.err("ON DELETE specified twice".into()));
5829                    }
5830                    seen_on_delete = true;
5831                    on_delete = action;
5832                }
5833                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5834                    if seen_on_update {
5835                        return Err(self.err("ON UPDATE specified twice".into()));
5836                    }
5837                    seen_on_update = true;
5838                    on_update = action;
5839                }
5840                other => {
5841                    return Err(
5842                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5843                    );
5844                }
5845            }
5846        }
5847        Ok((parent_table, parent_columns, on_delete, on_update))
5848    }
5849
5850    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5851    /// NO ACTION`.
5852    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5853        match self.advance() {
5854            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5855            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5856            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5857                Token::Null => Ok(FkAction::SetNull),
5858                Token::Default => Ok(FkAction::SetDefault),
5859                other => Err(self.err(format!(
5860                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5861                ))),
5862            },
5863            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5864                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5865                other => Err(self.err(format!(
5866                    "expected ACTION after NO in FK action, got {other:?}"
5867                ))),
5868            },
5869            other => Err(self.err(format!(
5870                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5871            ))),
5872        }
5873    }
5874
5875    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5876    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5877    fn consume_if_not_exists(&mut self) -> bool {
5878        // `IF` arrives as a bare Ident (we don't reserve it because it
5879        // also appears mid-expression in PG, though we don't support
5880        // those forms yet).
5881        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5882        if !looks_like_if {
5883            return false;
5884        }
5885        // Peek one ahead before committing: only consume IF when it's
5886        // actually `IF NOT EXISTS`.
5887        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5888            return false;
5889        }
5890        if !matches!(
5891            self.tokens.get(self.pos + 2),
5892            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5893        ) {
5894            return false;
5895        }
5896        self.advance(); // IF
5897        self.advance(); // NOT
5898        self.advance(); // EXISTS
5899        true
5900    }
5901
5902    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5903    /// Consumes IF EXISTS as a pair; returns false otherwise
5904    /// without consuming any tokens.
5905    fn consume_if_exists(&mut self) -> bool {
5906        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5907        if !looks_like_if {
5908            return false;
5909        }
5910        if !matches!(
5911            self.tokens.get(self.pos + 1),
5912            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5913        ) {
5914            return false;
5915        }
5916        self.advance(); // IF
5917        self.advance(); // EXISTS
5918        true
5919    }
5920
5921    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5922    /// qualifiers after an index column ref. ASC / DESC are
5923    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5924    /// We accept and discard them since single-column BTree
5925    /// stores rows in natural key order today.
5926    /// v7.24 (round-16 A) — `NULLS FIRST` / `NULLS LAST` after an
5927    /// ORDER BY key. Returns None when absent.
5928    fn parse_optional_nulls_placement(&mut self) -> Result<Option<bool>, ParseError> {
5929        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5930            return Ok(None);
5931        }
5932        self.advance();
5933        match self.advance() {
5934            Token::Ident(s) if s.eq_ignore_ascii_case("first") => Ok(Some(true)),
5935            Token::Ident(s) if s.eq_ignore_ascii_case("last") => Ok(Some(false)),
5936            other => Err(self.err(alloc::format!(
5937                "expected FIRST or LAST after NULLS, got {other:?}"
5938            ))),
5939        }
5940    }
5941
5942    fn consume_optional_index_column_qualifiers(&mut self) {
5943        loop {
5944            match self.peek() {
5945                Token::Asc | Token::Desc => {
5946                    self.advance();
5947                }
5948                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5949                    let look = self.tokens.get(self.pos + 1);
5950                    if matches!(
5951                        look,
5952                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5953                            || k.eq_ignore_ascii_case("last")
5954                    ) {
5955                        self.advance();
5956                        self.advance();
5957                    } else {
5958                        break;
5959                    }
5960                }
5961                _ => break,
5962            }
5963        }
5964    }
5965
5966    fn parse_create_index_stmt_after_create(
5967        &mut self,
5968        is_unique: bool,
5969    ) -> Result<Statement, ParseError> {
5970        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
5971        debug_assert!(matches!(self.peek(), Token::Index));
5972        self.advance();
5973        let if_not_exists = self.consume_if_not_exists();
5974        let name = self.expect_ident_like()?;
5975        if !matches!(self.peek(), Token::On) {
5976            return Err(self.err(format!(
5977                "expected ON after CREATE INDEX <name>, got {:?}",
5978                self.peek()
5979            )));
5980        }
5981        self.advance();
5982        let table = self.expect_ident_like()?;
5983        // Optional `USING <method>` — only recognised method in v2.0 is
5984        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
5985        // ident `using` (we don't promote it to a reserved keyword
5986        // because it isn't reserved anywhere else in our SQL surface).
5987        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5988            self.advance();
5989            let m = self.expect_ident_like()?;
5990            match m.to_ascii_lowercase().as_str() {
5991                "hnsw" => IndexMethod::Hnsw,
5992                "btree" => IndexMethod::BTree,
5993                "brin" => IndexMethod::Brin,
5994                // v7.12.3 — real GIN inverted index over `tsvector`.
5995                // v7.9.26b's `USING gin` → BTree silent fallback is
5996                // gone; the engine validates that the indexed column
5997                // is `tsvector` at CREATE INDEX time.
5998                "gin" => IndexMethod::Gin,
5999                // v7.9.26b — PG `pg_dump` emits `USING gist` /
6000                // `USING spgist` / `USING hash` for their built-in
6001                // AMs that SPG doesn't have a matching
6002                // implementation for; degrade to BTree on the
6003                // leading column so the schema loads + the index
6004                // catalogue stays consistent. Operator pays the
6005                // planner cost only for the queries that would have
6006                // used the specialised AM.
6007                "gist" | "spgist" | "hash" => IndexMethod::BTree,
6008                // v7.11.3 — pgvector ships both `ivfflat` and
6009                // `hnsw`. Customers shouldn't have to choose
6010                // their on-disk index method based on what SPG
6011                // implements; accept `ivfflat` as a synonym for
6012                // `hnsw` so PG schemas using either method drop
6013                // in. The vector distance op (`<->` / `<#>` /
6014                // `<=>`) at query time still picks the metric.
6015                "ivfflat" => IndexMethod::Hnsw,
6016                other => {
6017                    return Err(self.err(alloc::format!(
6018                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
6019                    )));
6020                }
6021            }
6022        } else {
6023            IndexMethod::BTree
6024        };
6025        if !matches!(self.peek(), Token::LParen) {
6026            return Err(self.err(format!(
6027                "expected '(' before indexed column, got {:?}",
6028                self.peek()
6029            )));
6030        }
6031        self.advance();
6032        // v6.8.2 — accept either a bare column ident (legacy) or
6033        // an expression `fn(col, …)` for expression indexes.
6034        // Distinguish by peeking the token *after* the current
6035        // ident: `ident )` is the legacy column-only path;
6036        // anything else triggers the Pratt expression parser.
6037        // (`advance()` uses `mem::replace` to nil out the current
6038        // slot, so we can't save+rewind cleanly — peek-ahead via
6039        // direct index avoids the mutation.)
6040        let mut opclass: Option<String> = None;
6041        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
6042            // Single column with `)` immediately after — fast path.
6043            // v7.9.29 — also: bare column followed by `,` (the
6044            // multi-column form `(a, b, c)`). Without this branch
6045            // the leading ident gets pulled into `parse_expr`
6046            // which then sets `expression = Some(Column(a))` and
6047            // breaks Display round-trip on the multi-column shape.
6048            Token::Ident(s) | Token::QuotedIdent(s)
6049                if matches!(
6050                    self.tokens.get(self.pos + 1),
6051                    Some(Token::RParen | Token::Comma)
6052                ) =>
6053            {
6054                self.advance();
6055                (s, None)
6056            }
6057            // v7.9.22 — single column followed by a pgvector
6058            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
6059            // v7.15.0 — capture the opclass instead of discarding
6060            // it so the engine can dispatch (e.g. `gin_trgm_ops`
6061            // → real trigram-shingle GIN over a TEXT column).
6062            // Vector/HNSW opclasses still take their distance
6063            // metric from the query operator (`<->` / `<#>` /
6064            // `<=>`), so for those callers the opclass stays
6065            // informational.
6066            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
6067            // opclass: `(embedding public.vector_cosine_ops)`. Strip
6068            // the schema and dispatch on the bare opclass, the same
6069            // treatment table/type names get.
6070            Token::Ident(s) | Token::QuotedIdent(s)
6071                if matches!(
6072                    self.tokens.get(self.pos + 1),
6073                    Some(Token::Ident(_) | Token::QuotedIdent(_))
6074                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
6075                    && matches!(
6076                        self.tokens.get(self.pos + 3),
6077                        Some(Token::Ident(op) | Token::QuotedIdent(op))
6078                            if is_vector_opclass_name(op)
6079                    ) =>
6080            {
6081                self.advance(); // column name
6082                self.advance(); // schema qualifier
6083                self.advance(); // dot
6084                let op_tok = self.advance();
6085                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6086                    opclass = Some(op.to_ascii_lowercase());
6087                }
6088                (s, None)
6089            }
6090            Token::Ident(s) | Token::QuotedIdent(s)
6091                if matches!(
6092                    self.tokens.get(self.pos + 1),
6093                    Some(Token::Ident(op) | Token::QuotedIdent(op))
6094                        if is_vector_opclass_name(op)
6095                ) =>
6096            {
6097                self.advance(); // column name
6098                // Capture the opclass token, lower-cased for
6099                // case-insensitive engine dispatch.
6100                let op_tok = self.advance();
6101                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
6102                    opclass = Some(op.to_ascii_lowercase());
6103                }
6104                (s, None)
6105            }
6106            Token::Ident(_) | Token::QuotedIdent(_) => {
6107                let key_expr = self.parse_expr(0)?;
6108                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6109                    self.err("expression index key must reference at least one column".into())
6110                })?;
6111                (primary, Some(key_expr))
6112            }
6113            // v7.37.43-T4 — parenthesised expression index key
6114            // `CREATE INDEX … ON t ((payload->'bundle'->>'id'))`.
6115            // PG's CREATE INDEX requires the expression to be in
6116            // its own parens to disambiguate function calls from
6117            // column lists, so this `LParen` is the inner open-paren
6118            // of an expression key. parse_expr handles the recursive
6119            // descent and consumes the matching `RParen`.
6120            Token::LParen => {
6121                let key_expr = self.parse_expr(0)?;
6122                let primary = extract_first_column(&key_expr).ok_or_else(|| {
6123                    self.err("expression index key must reference at least one column".into())
6124                })?;
6125                (primary, Some(key_expr))
6126            }
6127            other => {
6128                return Err(self.err(format!(
6129                    "expected column ident or expression, got {other:?}"
6130                )));
6131            }
6132        };
6133        // v7.9.14 — accept extra comma-separated columns inside
6134        // the index key parens (`CREATE INDEX … (a, b, c)`).
6135        // mailrs F2. Each extra column may carry an optional
6136        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
6137        // — parsed and discarded; SPG doesn't honour direction
6138        // on a BTree index today (column ordering is intrinsic
6139        // to the storage). v7.10 will widen to genuine composite
6140        // index keys.
6141        let mut extra_columns: Vec<String> = Vec::new();
6142        // The leading column may also have ASC/DESC after it.
6143        self.consume_optional_index_column_qualifiers();
6144        while matches!(self.peek(), Token::Comma) {
6145            self.advance();
6146            let extra = self.expect_ident_like()?;
6147            self.consume_optional_index_column_qualifiers();
6148            extra_columns.push(extra);
6149        }
6150        if !matches!(self.peek(), Token::RParen) {
6151            return Err(self.err(format!(
6152                "expected ')' after indexed column / expression, got {:?}",
6153                self.peek()
6154            )));
6155        }
6156        self.advance();
6157        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
6158        // index-only-scan annotation. Bare ident (not a reserved
6159        // keyword) so we test by case-insensitive string match.
6160        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
6161        {
6162            self.advance();
6163            if !matches!(self.peek(), Token::LParen) {
6164                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
6165            }
6166            self.advance();
6167            let mut cols = Vec::new();
6168            loop {
6169                cols.push(self.expect_ident_like()?);
6170                match self.peek() {
6171                    Token::Comma => {
6172                        self.advance();
6173                    }
6174                    Token::RParen => {
6175                        self.advance();
6176                        break;
6177                    }
6178                    other => {
6179                        return Err(self.err(format!(
6180                            "expected ',' or ')' in INCLUDE list, got {other:?}"
6181                        )));
6182                    }
6183                }
6184            }
6185            cols
6186        } else {
6187            Vec::new()
6188        };
6189        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
6190        // storage parameters. pgvector emits `WITH (lists = N)` for
6191        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
6192        // SPG's HNSW picks its own parameters today (tunable via
6193        // env vars), so the WITH clause is informational and dropped.
6194        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
6195            self.advance();
6196            if !matches!(self.peek(), Token::LParen) {
6197                return Err(self.err(format!(
6198                    "expected '(' after WITH in CREATE INDEX, got {:?}",
6199                    self.peek()
6200                )));
6201            }
6202            self.advance();
6203            loop {
6204                if matches!(self.peek(), Token::RParen) {
6205                    self.advance();
6206                    break;
6207                }
6208                // Drain `key = value` or bare `key` tokens.
6209                let _ = self.advance(); // key
6210                if matches!(self.peek(), Token::Eq) {
6211                    self.advance();
6212                    let _ = self.advance(); // value (int / string / ident)
6213                }
6214                match self.peek() {
6215                    Token::Comma => {
6216                        self.advance();
6217                    }
6218                    Token::RParen => {
6219                        self.advance();
6220                        break;
6221                    }
6222                    other => {
6223                        return Err(self.err(format!(
6224                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
6225                        )));
6226                    }
6227                }
6228            }
6229        }
6230        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
6231        let partial_predicate = if matches!(self.peek(), Token::Where) {
6232            self.advance();
6233            Some(self.parse_expr(0)?)
6234        } else {
6235            None
6236        };
6237        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
6238        // sense: uniqueness over an ANN structure has no clean
6239        // semantics. Reject early. (BRIN UNIQUE is similarly
6240        // meaningless — block both.)
6241        if is_unique && !matches!(method, IndexMethod::BTree) {
6242            return Err(self.err(alloc::format!(
6243                "UNIQUE is only supported on BTree indexes, got USING {:?}",
6244                method
6245            )));
6246        }
6247        Ok(Statement::CreateIndex(CreateIndexStatement {
6248            name,
6249            table,
6250            column,
6251            method,
6252            if_not_exists,
6253            included_columns,
6254            partial_predicate,
6255            extra_columns: extra_columns.clone(),
6256            expression,
6257            is_unique,
6258            opclass,
6259        }))
6260    }
6261
6262    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
6263    /// column-level `REFERENCES ...` clause. The trailing FK is
6264    /// normalised into table-level shape (single-element columns +
6265    /// parent_columns) so the engine sees one uniform constraint list.
6266    fn parse_column_def_with_fk(
6267        &mut self,
6268    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
6269        let col = self.parse_column_def()?;
6270        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
6271        let inline_references = matches!(
6272            self.peek(),
6273            Token::Ident(s) if s.eq_ignore_ascii_case("references")
6274        );
6275        if !inline_references {
6276            return Ok((col, None));
6277        }
6278        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
6279        let fk = ForeignKeyConstraint {
6280            name: None,
6281            columns: vec![col.name.clone()],
6282            parent_table,
6283            parent_columns,
6284            on_delete,
6285            on_update,
6286        };
6287        Ok((col, Some(fk)))
6288    }
6289
6290    /// v7.13.0 — parse a column type (consuming the type ident and
6291    /// any trailing parameters / `[]`), without surrounding column
6292    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
6293    /// Returns the resolved `ColumnTypeName` plus implied
6294    /// `(auto_increment, not_null)` flags from PG SERIAL family
6295    /// shorthands — callers that don't expect those (ALTER COLUMN
6296    /// TYPE) can discard them.
6297    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
6298        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
6299        Ok(ty)
6300    }
6301
6302    #[allow(clippy::type_complexity)]
6303    fn parse_type_with_implied_flags(
6304        &mut self,
6305    ) -> Result<
6306        (
6307            ColumnTypeName,
6308            bool,
6309            bool,
6310            Option<String>,
6311            Collation,
6312            bool,
6313            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
6314            // list captured at type-parse time. None for all
6315            // non-ENUM types.
6316            Option<Vec<String>>,
6317            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
6318            // list. Distinct from ENUM (subset semantics).
6319            Option<Vec<String>>,
6320        ),
6321        ParseError,
6322    > {
6323        let mut ty_ident = match self.advance() {
6324            Token::Ident(s) => s,
6325            // v7.37.5 β-P2 — `INTERVAL` lexes as a reserved keyword
6326            // (Token::Interval) since v7.9.25 to drive the `INTERVAL
6327            // '<span>'` literal grammar. As a column type it lands
6328            // here directly; downstream resolution still uses the
6329            // canonical lowercase string.
6330            Token::Interval => "interval".to_string(),
6331            other => {
6332                return Err(ParseError {
6333                    message: format!("expected column type, got {other:?}"),
6334                    token_pos: self.pos.saturating_sub(1),
6335                });
6336            }
6337        };
6338        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
6339        // pg_dump qualifies extension types (`public.vector(1024)`).
6340        // SPG is single-namespace; drop the schema and resolve the
6341        // bare type — same treatment table names already get.
6342        while matches!(self.peek(), Token::Dot) {
6343            self.advance();
6344            ty_ident = self.expect_ident_like()?;
6345        }
6346        let mut implied_auto_increment = false;
6347        let mut implied_not_null = false;
6348        let mut user_type_ref: Option<String> = None;
6349        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
6350        // value list, captured here and bubbled up through the
6351        // ColumnDef so the engine can attach it to the column
6352        // schema (and validate INSERT cells against it).
6353        let mut inline_enum_variants: Option<Vec<String>> = None;
6354        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
6355        let mut inline_set_variants: Option<Vec<String>> = None;
6356        let mut ty = match ty_ident.as_str() {
6357            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
6358            "smallserial" | "serial2" => {
6359                implied_auto_increment = true;
6360                implied_not_null = true;
6361                ColumnTypeName::SmallInt
6362            }
6363            "serial" | "serial4" => {
6364                implied_auto_increment = true;
6365                implied_not_null = true;
6366                ColumnTypeName::Int
6367            }
6368            "bigserial" | "serial8" => {
6369                implied_auto_increment = true;
6370                implied_not_null = true;
6371                ColumnTypeName::BigInt
6372            }
6373            // MySQL flavours we accept by aliasing to the closest SPG
6374            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
6375            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
6376            // 24-bit) → INT. UNSIGNED modifiers are consumed below
6377            // without semantic effect.
6378            "smallint" => {
6379                // v7.14.0 — MySQL display-width on integers
6380                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
6381                // parenthesised number is purely cosmetic — it
6382                // doesn't change storage. Accept + discard.
6383                self.consume_optional_paren_size();
6384                ColumnTypeName::SmallInt
6385            }
6386            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
6387            // canonical encoding for BOOLEAN. Every MySQL driver
6388            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
6389            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
6390            // 4.3 SPG classified TINYINT(1) as SmallInt, which
6391            // gave the customer i16-shaped values where the app
6392            // expected bool — a Tier-A silent type drift on
6393            // mysqldump restores. Now: `TINYINT(1)` → Bool;
6394            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
6395            // stay SmallInt (the legacy width-agnostic path).
6396            "tinyint" => {
6397                let width = self.peek_optional_paren_size_value();
6398                self.consume_optional_paren_size();
6399                if width == Some(1) {
6400                    ColumnTypeName::Bool
6401                } else {
6402                    ColumnTypeName::SmallInt
6403                }
6404            }
6405            "int" | "integer" | "mediumint" => {
6406                self.consume_optional_paren_size();
6407                ColumnTypeName::Int
6408            }
6409            "bigint" => {
6410                self.consume_optional_paren_size();
6411                ColumnTypeName::BigInt
6412            }
6413            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6414            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6415            // (mailrs round-5 G6). Consume the optional `PRECISION`
6416            // tail when the type keyword was `double` / `DOUBLE`.
6417            "float" | "double" | "real" => {
6418                if ty_ident.eq_ignore_ascii_case("double")
6419                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6420                {
6421                    self.advance();
6422                }
6423                ColumnTypeName::Float
6424            }
6425            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6426            "float4" | "float8" => ColumnTypeName::Float,
6427            "text" => ColumnTypeName::Text,
6428            "bool" | "boolean" => ColumnTypeName::Bool,
6429            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6430            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6431            "vector" => {
6432                let dim = self.parse_paren_size("VECTOR")?;
6433                let encoding = self.parse_optional_vector_encoding()?;
6434                ColumnTypeName::Vector { dim, encoding }
6435            }
6436            "numeric" => {
6437                let (precision, scale) = self.parse_optional_numeric_params()?;
6438                ColumnTypeName::Numeric(precision, scale)
6439            }
6440            "date" => ColumnTypeName::Date,
6441            // MySQL's `DATETIME` is the same domain as standard
6442            // `TIMESTAMP` — accept both spellings.
6443            "timestamp" | "datetime" => {
6444                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6445                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6446                // the full form. SPG canonicalises:
6447                //   - WITH TIME ZONE    → Timestamptz
6448                //   - WITHOUT TIME ZONE → Timestamp
6449                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6450                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6451                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6452                {
6453                    self.advance(); // WITH
6454                    self.advance(); // TIME
6455                    self.advance(); // ZONE
6456                    ColumnTypeName::Timestamptz
6457                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6458                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6459                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6460                {
6461                    self.advance(); // WITHOUT
6462                    self.advance(); // TIME
6463                    self.advance(); // ZONE
6464                    ColumnTypeName::Timestamp
6465                } else {
6466                    // Optional `(precision)` parenthesised modifier
6467                    // (PG fractional seconds precision). SPG stores
6468                    // µs always; accept + discard.
6469                    self.consume_optional_paren_size();
6470                    ColumnTypeName::Timestamp
6471                }
6472            }
6473            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6474            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6475            // only PG-wire OID differs.
6476            "timestamptz" => ColumnTypeName::Timestamptz,
6477            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6478            // validation. We accept the JSONB spelling too because
6479            // most PG clients default to it; SPG doesn't distinguish
6480            // the two (no path-operator perf advantage to model).
6481            "json" => ColumnTypeName::Json,
6482            "jsonb" => ColumnTypeName::Jsonb,
6483            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6484            // surface here. Same storage shape; mapping happens at
6485            // the engine side via the ColumnTypeName → DataType
6486            // resolver. Literal forms are handled at coerce_value
6487            // time so the lexer stays untouched.
6488            "bytea" | "bytes" => ColumnTypeName::Bytes,
6489            // v7.17.0 Phase 7 — PG network address types
6490            // v7.17.0 had a Text-backed fallback here for
6491            // `inet` / `cidr` / `macaddr`. v7.37.5 ζ-A promoted
6492            // each to a first-class type; the keywords are
6493            // bound below in the ζ-A block.
6494            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6495            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6496            // arrives in v7.12.1+; the type itself loads here so
6497            // mailrs's `scripts/init-schema.sql` runs unmodified.
6498            "tsvector" => ColumnTypeName::TsVector,
6499            "tsquery" => ColumnTypeName::TsQuery,
6500            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6501            // surface for Django / Rails / Hibernate's default
6502            // PK pattern.
6503            "uuid" => ColumnTypeName::Uuid,
6504            // v7.37.5 β-P2 — PG `INTERVAL` as a column type.
6505            // Storage = three-field {months, days, micros}, catalog
6506            // tag 34, FILE_VERSION 48+, wire OID 1186. Prior to this
6507            // line `INTERVAL` was parser-rejected at CREATE TABLE.
6508            "interval" => ColumnTypeName::Interval,
6509            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6510            // i64 microseconds since 00:00:00. Wire OID 1083.
6511            "time" => ColumnTypeName::Time,
6512            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6513            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6514            "year" => ColumnTypeName::Year,
6515            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6516            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6517            "timetz" => ColumnTypeName::TimeTz,
6518            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6519            // Wire OID 790.
6520            "money" => ColumnTypeName::Money,
6521            // v7.17.0 Phase 3.P0-38 — PG range types.
6522            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6523            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6524            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6525            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6526            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6527            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6528            // v7.37.5 δ — PG 14+ multirange keywords.
6529            "int4multirange" => ColumnTypeName::Multirange(RangeKindAst::Int4),
6530            "int8multirange" => ColumnTypeName::Multirange(RangeKindAst::Int8),
6531            "nummultirange" => ColumnTypeName::Multirange(RangeKindAst::Num),
6532            "tsmultirange" => ColumnTypeName::Multirange(RangeKindAst::Ts),
6533            "tstzmultirange" => ColumnTypeName::Multirange(RangeKindAst::TsTz),
6534            "datemultirange" => ColumnTypeName::Multirange(RangeKindAst::Date),
6535            // v7.37.5 ε — PG geometry scalar keywords.
6536            "point" => ColumnTypeName::Point,
6537            "lseg" => ColumnTypeName::Lseg,
6538            "path" => ColumnTypeName::Path,
6539            "box" => ColumnTypeName::PgBox,
6540            "polygon" => ColumnTypeName::Polygon,
6541            "line" => ColumnTypeName::Line,
6542            "circle" => ColumnTypeName::Circle,
6543            // v7.37.5 ζ-A — network / bit / xml / "char" keywords.
6544            "inet" => ColumnTypeName::Inet,
6545            "cidr" => ColumnTypeName::Cidr,
6546            "macaddr" => ColumnTypeName::Macaddr,
6547            "macaddr8" => ColumnTypeName::Macaddr8,
6548            "bit" => ColumnTypeName::Bit,
6549            "varbit" => ColumnTypeName::BitVarying,
6550            "xml" => ColumnTypeName::Xml,
6551            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6552            "hstore" => ColumnTypeName::Hstore,
6553            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6554            // `ENUM('a','b','c')`. Storage is TEXT; the value
6555            // list lands on `inline_enum_variants` for the
6556            // engine to validate INSERT cells against. Empty
6557            // value list is a parse error (matches MySQL).
6558            "enum" => {
6559                // Expect the opening `(`.
6560                if !matches!(self.peek(), Token::LParen) {
6561                    return Err(self.err(alloc::format!(
6562                        "expected '(' after ENUM, got {:?}",
6563                        self.peek()
6564                    )));
6565                }
6566                self.advance();
6567                let mut variants: Vec<String> = Vec::new();
6568                loop {
6569                    match self.advance() {
6570                        Token::String(s) => variants.push(s),
6571                        other => {
6572                            return Err(self.err(alloc::format!(
6573                                "ENUM(...) expects string literal variants, got {other:?}"
6574                            )));
6575                        }
6576                    }
6577                    match self.peek() {
6578                        Token::Comma => {
6579                            self.advance();
6580                            continue;
6581                        }
6582                        Token::RParen => {
6583                            self.advance();
6584                            break;
6585                        }
6586                        other => {
6587                            return Err(self.err(alloc::format!(
6588                                "expected ',' or ')' in ENUM(...), got {other:?}"
6589                            )));
6590                        }
6591                    }
6592                }
6593                if variants.is_empty() {
6594                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6595                }
6596                inline_enum_variants = Some(variants);
6597                // Storage is plain TEXT; the variant list lives on
6598                // the ColumnSchema side.
6599                ColumnTypeName::Text
6600            }
6601            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6602            // `SET('a','b','c')`. Same parse shape as ENUM;
6603            // semantics differ (subset rather than pick-one).
6604            "set" => {
6605                if !matches!(self.peek(), Token::LParen) {
6606                    return Err(self.err(alloc::format!(
6607                        "expected '(' after SET, got {:?}",
6608                        self.peek()
6609                    )));
6610                }
6611                self.advance();
6612                let mut variants: Vec<String> = Vec::new();
6613                loop {
6614                    match self.advance() {
6615                        Token::String(s) => variants.push(s),
6616                        other => {
6617                            return Err(self.err(alloc::format!(
6618                                "SET(...) expects string literal variants, got {other:?}"
6619                            )));
6620                        }
6621                    }
6622                    match self.peek() {
6623                        Token::Comma => {
6624                            self.advance();
6625                            continue;
6626                        }
6627                        Token::RParen => {
6628                            self.advance();
6629                            break;
6630                        }
6631                        other => {
6632                            return Err(self.err(alloc::format!(
6633                                "expected ',' or ')' in SET(...), got {other:?}"
6634                            )));
6635                        }
6636                    }
6637                }
6638                if variants.is_empty() {
6639                    return Err(self.err("SET(...) must declare at least one variant".into()));
6640                }
6641                inline_set_variants = Some(variants);
6642                ColumnTypeName::Text
6643            }
6644            _other => {
6645                // v7.17.0 Phase 1.4 — unknown ident → defer
6646                // resolution to the engine. Stored as Text in
6647                // ColumnTypeName + the original name carried as
6648                // `user_type_ref` so CREATE TABLE can look up
6649                // user-defined enum / domain types.
6650                user_type_ref = Some(ty_ident.clone());
6651                ColumnTypeName::Text
6652            }
6653        };
6654        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6655        // right after the type keyword. Pre-4.4 SPG consumed +
6656        // discarded the keyword, leaving a customer column
6657        // declared `id INT UNSIGNED NOT NULL` silently accepting
6658        // negative values — a Tier-A correctness drift where
6659        // application invariants (auto-increment-IDs never
6660        // negative) silently broke on cutover. Now: capture as
6661        // a column flag, persist on the schema, enforce at
6662        // INSERT / UPDATE time.
6663        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6664        {
6665            self.advance();
6666            true
6667        } else {
6668            false
6669        };
6670        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6671        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6672        // stores text as UTF-8 always so CHARACTER SET is still a
6673        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6674        // name: it gets classified into a `Collation` variant the
6675        // engine consults at WHERE-eval time. PG `default` /
6676        // `pg_catalog.default` / `C` / `POSIX` collations all
6677        // resolve to `Binary` (the prior behaviour); `_ci` /
6678        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6679        // The schema-qualifier form (`pg_catalog.default`) lexes
6680        // as `Ident '.' Ident` — peek for the `.` and consume both
6681        // halves so it's treated as one collation name. PG's
6682        // `IDENT.IDENT` collation form (which can appear here) is
6683        // resolved by Collation::from_collation_name on the bare
6684        // identifier after the dot.
6685        let mut collation = Collation::Binary;
6686        loop {
6687            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6688                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6689            {
6690                self.advance(); // CHARACTER
6691                self.advance(); // SET
6692                if matches!(
6693                    self.peek(),
6694                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6695                ) {
6696                    self.advance();
6697                }
6698                continue;
6699            }
6700            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6701                self.advance(); // COLLATE
6702                // Accept Ident / QuotedIdent / String AND the
6703                // keyword-tokenised `Default` (PG `pg_catalog.default`
6704                // and bare `DEFAULT` collation names — `default` is a
6705                // reserved word so the lexer hands back Token::Default
6706                // not Token::Ident).
6707                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6708                    match this.peek().clone() {
6709                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6710                            this.advance();
6711                            Some(s)
6712                        }
6713                        Token::Default => {
6714                            this.advance();
6715                            Some(alloc::string::String::from("default"))
6716                        }
6717                        _ => None,
6718                    }
6719                };
6720                let raw = if let Some(head) = read_collation_atom(self) {
6721                    // Schema-qualified PG form: `pg_catalog.default`.
6722                    if matches!(self.peek(), Token::Dot) {
6723                        self.advance();
6724                        let tail = read_collation_atom(self).unwrap_or_default();
6725                        alloc::format!("{head}.{tail}")
6726                    } else {
6727                        head
6728                    }
6729                } else {
6730                    alloc::string::String::new()
6731                };
6732                if !raw.is_empty() {
6733                    let parsed = Collation::from_collation_name(&raw);
6734                    // Last COLLATE clause wins, but `Binary` from a
6735                    // bare keyword like `default` should not
6736                    // silently downgrade a stronger one set earlier
6737                    // on the same column. v7.17 only ships one
6738                    // non-Binary variant so a simple OR is enough.
6739                    if parsed != Collation::Binary {
6740                        collation = parsed;
6741                    }
6742                }
6743                continue;
6744            }
6745            break;
6746        }
6747        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6748        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6749        // so we reject other base types here. mailrs uses TEXT[]
6750        // for labels / addresses / message-on-thread.
6751        if matches!(self.peek(), Token::LBracket) {
6752            self.advance();
6753            if !matches!(self.peek(), Token::RBracket) {
6754                return Err(self.err(alloc::format!(
6755                    "TEXT[] takes no dimension; got {:?}",
6756                    self.peek()
6757                )));
6758            }
6759            self.advance();
6760            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6761            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6762            // still error here.
6763            ty = match ty {
6764                ColumnTypeName::Text => ColumnTypeName::TextArray,
6765                ColumnTypeName::Int => ColumnTypeName::IntArray,
6766                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6767                // v7.37.5 β-P4 — INTERVAL[] via the same postfix
6768                // `[]` grammar. Wire OID 1187.
6769                ColumnTypeName::Interval => ColumnTypeName::IntervalArray,
6770                // v7.37.5 γ — full PG array-of-scalar family.
6771                ColumnTypeName::Bool => ColumnTypeName::BoolArray,
6772                ColumnTypeName::SmallInt => ColumnTypeName::SmallIntArray,
6773                ColumnTypeName::Float => ColumnTypeName::FloatArray,
6774                // NUMERIC(p, s) loses its precision params at the
6775                // array level (matches PG: `NUMERIC[]` is untyped,
6776                // per-element precision flows through values).
6777                ColumnTypeName::Numeric(_, _) => ColumnTypeName::NumericArray,
6778                ColumnTypeName::Date => ColumnTypeName::DateArray,
6779                ColumnTypeName::Timestamp => ColumnTypeName::TimestampArray,
6780                ColumnTypeName::Timestamptz => ColumnTypeName::TimestamptzArray,
6781                ColumnTypeName::Uuid => ColumnTypeName::UuidArray,
6782                ColumnTypeName::Json => ColumnTypeName::JsonArray,
6783                ColumnTypeName::Jsonb => ColumnTypeName::JsonbArray,
6784                ColumnTypeName::Bytes => ColumnTypeName::BytesArray,
6785                // VARCHAR(n)[] / CHAR(n)[] drop the length cap at
6786                // the array level (matches PG semantics where the
6787                // element precision is per-row, not column-wide).
6788                ColumnTypeName::Varchar(_) => ColumnTypeName::VarcharArray,
6789                ColumnTypeName::Char(_) => ColumnTypeName::CharArray,
6790                // v7.37.5 ζ-A — MONEY[] (OID 791) ship-triage
6791                // follow-up.
6792                ColumnTypeName::Money => ColumnTypeName::MoneyArray,
6793                other => {
6794                    return Err(self.err(alloc::format!("{other:?}[] not yet supported")));
6795                }
6796            };
6797            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6798            // for INT/TEXT/BIGINT. Anything else is an error.
6799            if matches!(self.peek(), Token::LBracket) {
6800                self.advance();
6801                if !matches!(self.peek(), Token::RBracket) {
6802                    return Err(self.err(alloc::format!(
6803                        "TYPE[][] second dimension takes no size; got {:?}",
6804                        self.peek()
6805                    )));
6806                }
6807                self.advance();
6808                ty = match ty {
6809                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6810                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6811                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6812                    other => {
6813                        return Err(self.err(alloc::format!(
6814                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6815                             TEXT[][] only; got {other:?}"
6816                        )));
6817                    }
6818                };
6819            }
6820        }
6821        Ok((
6822            ty,
6823            implied_auto_increment,
6824            implied_not_null,
6825            user_type_ref,
6826            collation,
6827            is_unsigned,
6828            inline_enum_variants,
6829            inline_set_variants,
6830        ))
6831    }
6832
6833    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6834        // v7.20 — PG reserves the table-constraint keywords, so a
6835        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6836        // malformed constraint clause (e.g. `UNIQUE a` missing its
6837        // parens), not a column named "unique". Since v7.17's
6838        // unknown-type leniency (`user_type_ref`) such a clause
6839        // would otherwise parse as a column with a user-defined
6840        // type — silently accepting invalid DDL. Quoted
6841        // identifiers ("unique" / `unique`) remain valid names.
6842        if let Token::Ident(s) = self.peek()
6843            && [
6844                "unique",
6845                "primary",
6846                "foreign",
6847                "constraint",
6848                "check",
6849                "references",
6850                "exclude",
6851            ]
6852            .iter()
6853            .any(|kw| s.eq_ignore_ascii_case(kw))
6854        {
6855            return Err(self.err(alloc::format!(
6856                "unexpected reserved keyword '{s}' at start of column definition \
6857                 (malformed table constraint?)"
6858            )));
6859        }
6860        let name = self.expect_ident_like()?;
6861        let (
6862            ty,
6863            implied_auto_increment,
6864            implied_not_null,
6865            user_type_ref,
6866            collation,
6867            is_unsigned,
6868            inline_enum_variants,
6869            inline_set_variants,
6870        ) = self.parse_type_with_implied_flags()?;
6871        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6872        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6873        // each at most once.
6874        let mut default: Option<Expr> = None;
6875        let mut nullable = !implied_not_null;
6876        let mut nullability_seen = implied_not_null;
6877        let mut auto_increment = implied_auto_increment;
6878        let mut is_primary_key = false;
6879        let mut is_unique = false;
6880        let mut check: Option<Expr> = None;
6881        let mut on_update_runtime: Option<Expr> = None;
6882        let mut generated_stored_expr: Option<Box<Expr>> = None;
6883        loop {
6884            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
6885            // not-null constraints by name and pg_dump emits them
6886            // inline: `id bigint CONSTRAINT contacts_id_not_null1
6887            // NOT NULL`. Accept and discard the name; whatever
6888            // constraint follows is parsed by the arms below.
6889            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
6890                self.advance();
6891                let _name = self.expect_ident_like()?;
6892                continue;
6893            }
6894            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
6895            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
6896            // the modern replacement for SERIAL in hand-written
6897            // schemas). Both flavours map onto the auto-increment
6898            // machinery — SPG's serial semantics ≈ BY DEFAULT;
6899            // ALWAYS's reject-explicit-values nuance is documented
6900            // leniency. Generated EXPRESSION columns
6901            // (`AS (expr) STORED`) are not supported: error loudly
6902            // instead of silently storing NULLs.
6903            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
6904                self.advance();
6905                match self.peek().clone() {
6906                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
6907                        self.advance();
6908                    }
6909                    // `BY` is a reserved keyword token (GROUP BY).
6910                    Token::By => {
6911                        self.advance();
6912                        if !matches!(self.peek(), Token::Default) {
6913                            return Err(self.err(alloc::format!(
6914                                "expected DEFAULT after GENERATED BY, got {:?}",
6915                                self.peek()
6916                            )));
6917                        }
6918                        self.advance();
6919                    }
6920                    other => {
6921                        return Err(self.err(alloc::format!(
6922                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
6923                        )));
6924                    }
6925                }
6926                if !matches!(self.peek(), Token::As) {
6927                    return Err(self.err(alloc::format!(
6928                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
6929                        self.peek()
6930                    )));
6931                }
6932                self.advance();
6933                // v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS
6934                // ( <expr> ) STORED` stored computed-column. The
6935                // expression is captured for the engine to recompute
6936                // on every INSERT / UPDATE. v7.37.7 accepts the
6937                // STORED keyword only; PG also has VIRTUAL, which
6938                // v7.37.7 carves out (sentori only uses STORED).
6939                if matches!(self.peek(), Token::LParen) {
6940                    self.advance();
6941                    let expr = self.parse_expr(0)?;
6942                    if !matches!(self.peek(), Token::RParen) {
6943                        return Err(self.err(alloc::format!(
6944                            "expected ')' after GENERATED ALWAYS AS (<expr>), got {:?}",
6945                            self.peek()
6946                        )));
6947                    }
6948                    self.advance();
6949                    let stored = match self.peek() {
6950                        Token::Ident(s) | Token::QuotedIdent(s)
6951                            if s.eq_ignore_ascii_case("stored") =>
6952                        {
6953                            self.advance();
6954                            true
6955                        }
6956                        Token::Ident(s) | Token::QuotedIdent(s)
6957                            if s.eq_ignore_ascii_case("virtual") =>
6958                        {
6959                            return Err(self.err(
6960                                "GENERATED ALWAYS AS (expr) VIRTUAL is not supported \
6961                                 at v7.37.7; use STORED"
6962                                    .into(),
6963                            ));
6964                        }
6965                        other => {
6966                            return Err(self.err(alloc::format!(
6967                                "expected STORED after GENERATED ALWAYS AS (<expr>), \
6968                                 got {other:?}"
6969                            )));
6970                        }
6971                    };
6972                    let _ = stored; // currently STORED-only; flag reserved for VIRTUAL.
6973                    generated_stored_expr = Some(Box::new(expr));
6974                    continue;
6975                }
6976                self.expect_keyword_ident("identity")?;
6977                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
6978                // consume the balanced parens and discard (SPG's
6979                // auto-increment is max+1-scan based).
6980                if matches!(self.peek(), Token::LParen) {
6981                    let mut depth = 0usize;
6982                    loop {
6983                        match self.advance() {
6984                            Token::LParen => depth += 1,
6985                            Token::RParen => {
6986                                depth -= 1;
6987                                if depth == 0 {
6988                                    break;
6989                                }
6990                            }
6991                            Token::Eof => {
6992                                return Err(self.err(
6993                                    "unterminated sequence-options parens after IDENTITY".into(),
6994                                ));
6995                            }
6996                            _ => {}
6997                        }
6998                    }
6999                }
7000                auto_increment = true;
7001                // PG identity columns are implicitly NOT NULL.
7002                nullable = false;
7003                continue;
7004            }
7005            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
7006            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
7007            // is accepted today. The "ON" token is an Ident
7008            // (not reserved) — peek before consuming.
7009            if matches!(self.peek(), Token::On)
7010                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
7011            {
7012                self.advance(); // ON
7013                self.advance(); // update
7014                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
7015                let next = self.peek().clone();
7016                match next {
7017                    Token::Ident(s) | Token::QuotedIdent(s)
7018                        if s.eq_ignore_ascii_case("current_timestamp") =>
7019                    {
7020                        self.advance();
7021                        // Optional `(N)` precision.
7022                        if matches!(self.peek(), Token::LParen) {
7023                            self.advance();
7024                            if !matches!(self.peek(), Token::Integer(_)) {
7025                                return Err(self.err(alloc::format!(
7026                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
7027                                    self.peek()
7028                                )));
7029                            }
7030                            self.advance();
7031                            if !matches!(self.peek(), Token::RParen) {
7032                                return Err(self.err(alloc::format!(
7033                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
7034                                    self.peek()
7035                                )));
7036                            }
7037                            self.advance();
7038                        }
7039                        on_update_runtime = Some(Expr::FunctionCall {
7040                            name: "now".into(),
7041                            args: Vec::new(),
7042                        });
7043                        continue;
7044                    }
7045                    other => {
7046                        return Err(self.err(alloc::format!(
7047                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
7048                        )));
7049                    }
7050                }
7051            }
7052            if matches!(self.peek(), Token::Default) {
7053                if default.is_some() {
7054                    return Err(self.err("DEFAULT specified twice".into()));
7055                }
7056                self.advance();
7057                default = Some(self.parse_expr(0)?);
7058                continue;
7059            }
7060            if matches!(self.peek(), Token::Not) {
7061                if nullability_seen {
7062                    return Err(self.err("NOT NULL specified twice".into()));
7063                }
7064                self.advance();
7065                if !matches!(self.peek(), Token::Null) {
7066                    return Err(self.err(format!(
7067                        "expected NULL after NOT in column def, got {:?}",
7068                        self.peek()
7069                    )));
7070                }
7071                self.advance();
7072                nullable = false;
7073                nullability_seen = true;
7074                continue;
7075            }
7076            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
7077            // "this column is nullable" marker (the default in
7078            // standard SQL anyway). mysqldump emits it routinely
7079            // (`col TYPE NULL DEFAULT NULL` for nullable
7080            // timestamps etc). Accept + no-op.
7081            if matches!(self.peek(), Token::Null) {
7082                if nullability_seen && !nullable {
7083                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
7084                }
7085                self.advance();
7086                nullable = true;
7087                nullability_seen = true;
7088                continue;
7089            }
7090            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
7091            // arrives as a bare Ident. Match either, case-insensitive.
7092            if let Token::Ident(s) = self.peek()
7093                && (s.eq_ignore_ascii_case("auto_increment")
7094                    || s.eq_ignore_ascii_case("autoincrement"))
7095            {
7096                if auto_increment {
7097                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
7098                }
7099                self.advance();
7100                auto_increment = true;
7101                continue;
7102            }
7103            // v7.9.13 — inline `PRIMARY KEY` column constraint
7104            // (mailrs F1). Implies `NOT NULL`. The engine creates
7105            // a BTree index for the PK column at CREATE TABLE time
7106            // so FK parent-side index lookups resolve.
7107            if let Token::Ident(s) = self.peek()
7108                && s.eq_ignore_ascii_case("primary")
7109            {
7110                if is_primary_key {
7111                    return Err(self.err("PRIMARY KEY specified twice".into()));
7112                }
7113                // Peek-ahead for the required `KEY` token.
7114                let next = self.tokens.get(self.pos + 1);
7115                let next_is_key = matches!(
7116                    next,
7117                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
7118                );
7119                if !next_is_key {
7120                    return Err(self.err(format!(
7121                        "expected KEY after PRIMARY in column def, got {:?}",
7122                        next
7123                    )));
7124                }
7125                self.advance(); // PRIMARY
7126                self.advance(); // KEY
7127                is_primary_key = true;
7128                if nullability_seen && nullable {
7129                    return Err(self.err(
7130                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
7131                    ));
7132                }
7133                nullable = false;
7134                nullability_seen = true;
7135                continue;
7136            }
7137            // v7.13.0 — inline `UNIQUE` column constraint
7138            // (mailrs round-5 G2). Fold into a single-column
7139            // table-level UNIQUE at CREATE TABLE post-process time.
7140            if let Token::Ident(s) = self.peek()
7141                && s.eq_ignore_ascii_case("unique")
7142            {
7143                if is_unique {
7144                    return Err(self.err("UNIQUE specified twice".into()));
7145                }
7146                self.advance();
7147                is_unique = true;
7148                continue;
7149            }
7150            // v7.13.0 — inline `CHECK (<expr>)` column constraint
7151            // (mailrs round-5 G3). PG semantics: column-level
7152            // CHECK is equivalent to a table-level CHECK. Multiple
7153            // inline CHECKs on the same column AND together.
7154            if let Token::Ident(s) = self.peek()
7155                && s.eq_ignore_ascii_case("check")
7156            {
7157                self.advance();
7158                if !matches!(self.peek(), Token::LParen) {
7159                    return Err(self.err(alloc::format!(
7160                        "expected '(' after CHECK in column def, got {:?}",
7161                        self.peek()
7162                    )));
7163                }
7164                self.advance();
7165                let pred = self.parse_expr(0)?;
7166                if !matches!(self.peek(), Token::RParen) {
7167                    return Err(self.err(alloc::format!(
7168                        "expected ')' to close CHECK predicate, got {:?}",
7169                        self.peek()
7170                    )));
7171                }
7172                self.advance();
7173                check = Some(match check.take() {
7174                    Some(prev) => Expr::Binary {
7175                        op: BinOp::And,
7176                        lhs: Box::new(prev),
7177                        rhs: Box::new(pred),
7178                    },
7179                    None => pred,
7180                });
7181                continue;
7182            }
7183            break;
7184        }
7185        Ok(ColumnDef {
7186            name,
7187            ty,
7188            nullable,
7189            default,
7190            auto_increment,
7191            is_primary_key,
7192            is_unique,
7193            check,
7194            user_type_ref,
7195            on_update_runtime,
7196            collation,
7197            is_unsigned,
7198            inline_enum_variants,
7199            inline_set_variants,
7200            generated_stored_expr,
7201        })
7202    }
7203
7204    /// `NUMERIC` may appear without parameters, with one (precision
7205    /// only, scale=0), or with both. Returns `(precision, scale)` with
7206    /// 0 = unspecified for the bare form.
7207    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
7208        if !matches!(self.peek(), Token::LParen) {
7209            // Bare `NUMERIC` — PG treats this as "unlimited precision";
7210            // we surface it as precision=0 to mean "unconstrained" so
7211            // the engine doesn't need a separate variant.
7212            return Ok((0, 0));
7213        }
7214        self.advance();
7215        let precision = match self.advance() {
7216            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
7217            other => {
7218                return Err(ParseError {
7219                    message: format!(
7220                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
7221                    ),
7222                    token_pos: self.pos.saturating_sub(1),
7223                });
7224            }
7225        };
7226        let scale = if matches!(self.peek(), Token::Comma) {
7227            self.advance();
7228            match self.advance() {
7229                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
7230                    u8::try_from(n).expect("range-checked")
7231                }
7232                other => {
7233                    return Err(ParseError {
7234                        message: format!(
7235                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
7236                        ),
7237                        token_pos: self.pos.saturating_sub(1),
7238                    });
7239                }
7240            }
7241        } else {
7242            0
7243        };
7244        if !matches!(self.peek(), Token::RParen) {
7245            return Err(self.err(format!(
7246                "expected ')' to close NUMERIC params, got {:?}",
7247                self.peek()
7248            )));
7249        }
7250        self.advance();
7251        Ok((precision, scale))
7252    }
7253
7254    /// Parse `(N)` where `N` is a positive integer literal — used by the
7255    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
7256    /// for the error message.
7257    /// v6.0.1: parse the optional `USING <encoding>` clause that
7258    /// follows `VECTOR(N)` in a column definition. Missing clause
7259    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
7260    /// ident → `ParseError` listing the encodings recognised today.
7261    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
7262        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
7263            return Ok(VecEncoding::F32);
7264        }
7265        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
7266        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
7267        // consume the token when the very next token is a known
7268        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
7269        // `USING` for the caller — it's the rewrite-expression form.
7270        let n1 = self.tokens.get(self.pos + 1);
7271        let next_is_encoding = matches!(
7272            n1,
7273            Some(Token::Ident(s))
7274                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
7275        );
7276        if !next_is_encoding {
7277            return Ok(VecEncoding::F32);
7278        }
7279        self.advance();
7280        let enc_ident = match self.advance() {
7281            Token::Ident(s) => s,
7282            other => {
7283                return Err(self.err(format!(
7284                    "expected vector encoding after USING, got {other:?}"
7285                )));
7286            }
7287        };
7288        match enc_ident.to_ascii_lowercase().as_str() {
7289            "sq8" => Ok(VecEncoding::Sq8),
7290            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
7291            // binary16 per-element storage.
7292            "half" => Ok(VecEncoding::F16),
7293            other => Err(self.err(format!(
7294                "unknown vector encoding {other:?}; supported: SQ8, HALF"
7295            ))),
7296        }
7297    }
7298
7299    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
7300    /// without consuming it. Returns `Some(N)` when the next
7301    /// tokens are `( <int> )`; None otherwise. Used by the
7302    /// TINYINT classifier to decide whether to map to Bool or
7303    /// SmallInt.
7304    fn peek_optional_paren_size_value(&self) -> Option<i64> {
7305        if !matches!(self.peek(), Token::LParen) {
7306            return None;
7307        }
7308        let next = self.tokens.get(self.pos + 1)?;
7309        let n = match next {
7310            Token::Integer(n) => *n,
7311            _ => return None,
7312        };
7313        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
7314            return None;
7315        }
7316        Some(n)
7317    }
7318
7319    /// v7.14.0 — consume an optional MySQL display-width
7320    /// parenthesised number after an integer type, returning
7321    /// nothing. `TINYINT(1)` etc.
7322    fn consume_optional_paren_size(&mut self) {
7323        if !matches!(self.peek(), Token::LParen) {
7324            return;
7325        }
7326        self.advance();
7327        // Skip until matching RParen (allow nested or any tokens).
7328        let mut depth = 1usize;
7329        while depth > 0 {
7330            match self.peek() {
7331                Token::LParen => depth += 1,
7332                Token::RParen => depth -= 1,
7333                Token::Eof => return,
7334                _ => {}
7335            }
7336            self.advance();
7337        }
7338    }
7339
7340    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
7341        if !matches!(self.peek(), Token::LParen) {
7342            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
7343        }
7344        self.advance();
7345        let n = match self.advance() {
7346            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
7347                message: format!("{label} size too large: {n}"),
7348                token_pos: self.pos.saturating_sub(1),
7349            })?,
7350            other => {
7351                return Err(ParseError {
7352                    message: format!("expected positive integer {label} size, got {other:?}"),
7353                    token_pos: self.pos.saturating_sub(1),
7354                });
7355            }
7356        };
7357        if !matches!(self.peek(), Token::RParen) {
7358            return Err(self.err(format!(
7359                "expected ')' after {label} size, got {:?}",
7360                self.peek()
7361            )));
7362        }
7363        self.advance();
7364        Ok(n)
7365    }
7366
7367    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
7368        debug_assert!(matches!(self.peek(), Token::Insert));
7369        self.advance();
7370        if !matches!(self.peek(), Token::Into) {
7371            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
7372        }
7373        self.advance();
7374        let table = self.expect_ident_like()?;
7375        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
7376        let columns = if matches!(self.peek(), Token::LParen) {
7377            self.advance();
7378            let mut names = Vec::new();
7379            loop {
7380                names.push(self.expect_ident_like()?);
7381                match self.peek() {
7382                    Token::Comma => {
7383                        self.advance();
7384                    }
7385                    Token::RParen => {
7386                        self.advance();
7387                        break;
7388                    }
7389                    other => {
7390                        return Err(self.err(format!(
7391                            "expected ',' or ')' in INSERT column list, got {other:?}"
7392                        )));
7393                    }
7394                }
7395            }
7396            Some(names)
7397        } else {
7398            None
7399        };
7400        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
7401        // round-5 G4). Dispatch on VALUES vs SELECT.
7402        if matches!(self.peek(), Token::Select) {
7403            let select_stmt = match self.parse_select_stmt()? {
7404                Statement::Select(s) => s,
7405                other => {
7406                    return Err(self.err(alloc::format!(
7407                        "expected SELECT after INSERT INTO ... target, got {other:?}"
7408                    )));
7409                }
7410            };
7411            let on_conflict = self.parse_optional_on_conflict()?;
7412            let returning = self.parse_optional_returning()?;
7413            return Ok(Statement::Insert(InsertStatement {
7414                ctes: Vec::new(),
7415                table,
7416                columns,
7417                rows: Vec::new(),
7418                select_source: Some(Box::new(select_stmt)),
7419                on_conflict,
7420                returning,
7421            }));
7422        }
7423        if !matches!(self.peek(), Token::Values) {
7424            return Err(self.err(format!(
7425                "expected VALUES or SELECT after table name, got {:?}",
7426                self.peek()
7427            )));
7428        }
7429        self.advance();
7430        if !matches!(self.peek(), Token::LParen) {
7431            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
7432        }
7433        let mut rows = Vec::new();
7434        loop {
7435            // Each iteration consumes one `(expr, expr, …)` tuple.
7436            if !matches!(self.peek(), Token::LParen) {
7437                return Err(self.err(format!(
7438                    "expected '(' for next VALUES tuple, got {:?}",
7439                    self.peek()
7440                )));
7441            }
7442            self.advance();
7443            let mut tuple = Vec::new();
7444            loop {
7445                tuple.push(self.parse_expr(0)?);
7446                match self.peek() {
7447                    Token::Comma => {
7448                        self.advance();
7449                    }
7450                    Token::RParen => {
7451                        self.advance();
7452                        break;
7453                    }
7454                    other => {
7455                        return Err(self.err(format!(
7456                            "expected ',' or ')' in VALUES tuple, got {other:?}"
7457                        )));
7458                    }
7459                }
7460            }
7461            if tuple.is_empty() {
7462                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
7463            }
7464            rows.push(tuple);
7465            // Continue with comma-separated tuples.
7466            if matches!(self.peek(), Token::Comma) {
7467                self.advance();
7468            } else {
7469                break;
7470            }
7471        }
7472        let on_conflict = self.parse_optional_on_conflict()?;
7473        let returning = self.parse_optional_returning()?;
7474        Ok(Statement::Insert(InsertStatement {
7475            ctes: Vec::new(),
7476            table,
7477            columns,
7478            rows,
7479            select_source: None,
7480            on_conflict,
7481            returning,
7482        }))
7483    }
7484
7485    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
7486    /// clause sitting between the INSERT body and the trailing
7487    /// RETURNING. All keywords come in as bare idents; `ON` is
7488    /// a reserved Token though.
7489    fn parse_optional_on_conflict(
7490        &mut self,
7491    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7492        if !matches!(self.peek(), Token::On) {
7493            return Ok(None);
7494        }
7495        // Peek further: we want exactly "ON CONFLICT ...". If the
7496        // next ident isn't "conflict", let some other parser handle.
7497        let next_is_conflict = matches!(
7498            self.tokens.get(self.pos + 1),
7499            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7500        );
7501        if !next_is_conflict {
7502            return Ok(None);
7503        }
7504        self.advance(); // ON
7505        self.advance(); // CONFLICT
7506        // Optional `(col [, col]*)` target list.
7507        let mut target_columns: Vec<String> = Vec::new();
7508        if matches!(self.peek(), Token::LParen) {
7509            self.advance();
7510            loop {
7511                target_columns.push(self.expect_ident_like()?);
7512                match self.peek() {
7513                    Token::Comma => {
7514                        self.advance();
7515                    }
7516                    Token::RParen => {
7517                        self.advance();
7518                        break;
7519                    }
7520                    other => {
7521                        return Err(self.err(alloc::format!(
7522                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7523                        )));
7524                    }
7525                }
7526            }
7527        }
7528        // Required `DO`.
7529        match self.advance() {
7530            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7531            other => {
7532                return Err(self.err(alloc::format!(
7533                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7534                )));
7535            }
7536        }
7537        // Action: NOTHING | UPDATE SET …
7538        let action = match self.advance() {
7539            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7540                crate::ast::OnConflictAction::Nothing
7541            }
7542            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7543                self.parse_on_conflict_update_action()?
7544            }
7545            other => {
7546                return Err(self.err(alloc::format!(
7547                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7548                )));
7549            }
7550        };
7551        Ok(Some(crate::ast::OnConflictClause {
7552            target_columns,
7553            action,
7554        }))
7555    }
7556
7557    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7558    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7559    /// consumed `UPDATE`.
7560    fn parse_on_conflict_update_action(
7561        &mut self,
7562    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7563        // `SET`
7564        match self.advance() {
7565            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7566            other => {
7567                return Err(self.err(alloc::format!(
7568                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7569                )));
7570            }
7571        }
7572        let mut assignments: Vec<(String, Expr)> = Vec::new();
7573        loop {
7574            let col = self.expect_ident_like()?;
7575            if !matches!(self.peek(), Token::Eq) {
7576                return Err(self.err(alloc::format!(
7577                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7578                    self.peek()
7579                )));
7580            }
7581            self.advance();
7582            let value = self.parse_expr(0)?;
7583            assignments.push((col, value));
7584            if matches!(self.peek(), Token::Comma) {
7585                self.advance();
7586                continue;
7587            }
7588            break;
7589        }
7590        let where_ = if matches!(self.peek(), Token::Where) {
7591            self.advance();
7592            Some(self.parse_expr(0)?)
7593        } else {
7594            None
7595        };
7596        Ok(crate::ast::OnConflictAction::Update {
7597            assignments,
7598            where_,
7599        })
7600    }
7601
7602    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7603        let mut items = Vec::new();
7604        loop {
7605            items.push(self.parse_select_item()?);
7606            if matches!(self.peek(), Token::Comma) {
7607                self.advance();
7608            } else {
7609                break;
7610            }
7611        }
7612        Ok(items)
7613    }
7614
7615    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7616        if matches!(self.peek(), Token::Star) {
7617            self.advance();
7618            return Ok(SelectItem::Wildcard);
7619        }
7620        let expr = self.parse_expr(0)?;
7621        let alias = self.parse_optional_alias();
7622        Ok(SelectItem::Expr { expr, alias })
7623    }
7624
7625    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7626        // v7.37.43-T4.5 — `LATERAL jsonb_each_text(<expr>)` —
7627        // set-returning function whose argument may reference a
7628        // preceding FROM item. We rewrite this to
7629        // `LATERAL (SELECT key, value FROM jsonb_each_text(<expr>)
7630        // AS __srf__) AS <alias>` so the existing LATERAL subquery
7631        // executor handles per-outer-row evaluation and the
7632        // SRF-primary jsonb_each_text path handles the inner
7633        // materialisation. Sentori 0067 backfill is the dogfood
7634        // shape.
7635        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7636            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("jsonb_each_text"))
7637            && matches!(self.tokens.get(self.pos + 2), Some(Token::LParen))
7638        {
7639            self.advance(); // LATERAL
7640            self.advance(); // jsonb_each_text
7641            self.advance(); // (
7642            let arg = self.parse_expr(0)?;
7643            if !matches!(self.peek(), Token::RParen) {
7644                return Err(self.err(alloc::format!(
7645                    "expected ')' after LATERAL jsonb_each_text() argument, got {:?}",
7646                    self.peek()
7647                )));
7648            }
7649            self.advance();
7650            let (alias_ident, column_aliases) = self.parse_optional_alias_with_columns();
7651            let alias = alias_ident
7652                .clone()
7653                .unwrap_or_else(|| "jsonb_each_text".to_string());
7654            // Synthesise: SELECT __srf__.key AS <key_alias>, __srf__.value AS <value_alias>
7655            //               FROM jsonb_each_text(<arg>) AS __srf__
7656            // PG's `AS kv(key, value)` column-alias list maps
7657            // positions to names; default to (key, value) when
7658            // omitted (matching the SRF's natural column names).
7659            let srf_alias = "__srf__".to_string();
7660            let key_alias = column_aliases
7661                .first()
7662                .cloned()
7663                .unwrap_or_else(|| "key".to_string());
7664            let value_alias = column_aliases
7665                .get(1)
7666                .cloned()
7667                .unwrap_or_else(|| "value".to_string());
7668            let inner_select = crate::ast::SelectStatement {
7669                ctes: Vec::new(),
7670                distinct: false,
7671                items: alloc::vec![
7672                    crate::ast::SelectItem::Expr {
7673                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7674                            qualifier: Some(srf_alias.clone()),
7675                            name: "key".to_string(),
7676                        }),
7677                        alias: Some(key_alias),
7678                    },
7679                    crate::ast::SelectItem::Expr {
7680                        expr: crate::ast::Expr::Column(crate::ast::ColumnName {
7681                            qualifier: Some(srf_alias.clone()),
7682                            name: "value".to_string(),
7683                        }),
7684                        alias: Some(value_alias),
7685                    },
7686                ],
7687                from: Some(crate::ast::FromClause {
7688                    primary: TableRef {
7689                        name: srf_alias.clone(),
7690                        alias: Some(srf_alias.clone()),
7691                        as_of_segment: None,
7692                        unnest_expr: None,
7693                        unnest_column_aliases: Vec::new(),
7694                        generate_series_args: None,
7695                        lateral_subquery: None,
7696                        jsonb_each_text_arg: Some(Box::new(arg)),
7697                    },
7698                    joins: Vec::new(),
7699                }),
7700                where_: None,
7701                group_by: None,
7702                group_by_all: false,
7703                having: None,
7704                unions: Vec::new(),
7705                order_by: Vec::new(),
7706                limit: None,
7707                offset: None,
7708                limit_with_ties: false,
7709            };
7710            return Ok(TableRef {
7711                name: alias.clone(),
7712                alias: Some(alias),
7713                as_of_segment: None,
7714                unnest_expr: None,
7715                unnest_column_aliases: Vec::new(),
7716                generate_series_args: None,
7717                lateral_subquery: Some(Box::new(inner_select)),
7718                jsonb_each_text_arg: None,
7719            });
7720        }
7721        // v7.37.43-T4.5 — bare `CROSS JOIN jsonb_each_text(t.col)`
7722        // without an explicit `LATERAL` keyword is the same shape
7723        // PG accepts (SRF naturally licences lateral correlation).
7724        // We mirror the LATERAL rewrite when the argument syntactic-
7725        // ally references an outer column (Column { qualifier:
7726        // Some(_), … }). For simplicity we apply the rewrite
7727        // whenever the SRF directly follows JOIN/CROSS JOIN/comma
7728        // in the FROM-list — caller-side join parsing positions
7729        // this peek correctly.
7730        // (Implementation note: detection lives below; the LATERAL
7731        // branch above already covers the explicit form; the bare
7732        // form falls through to the plain SRF arm and the engine
7733        // treats it as a constant-arg SRF if no outer reference is
7734        // present.)
7735        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7736        // table. Detect at the head so it claims precedence over
7737        // every other table-ref shape (unnest / generate_series /
7738        // bare ident); the lateral subquery itself follows the
7739        // regular SELECT grammar.
7740        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7741            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7742        {
7743            self.advance(); // LATERAL
7744            self.advance(); // (
7745            // Parse the inner SELECT.
7746            let inner = match self.parse_one_statement()? {
7747                Statement::Select(s) => s,
7748                other => {
7749                    return Err(self.err(alloc::format!(
7750                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7751                    )));
7752                }
7753            };
7754            if !matches!(self.peek(), Token::RParen) {
7755                return Err(self.err(alloc::format!(
7756                    "expected ')' after LATERAL subquery, got {:?}",
7757                    self.peek()
7758                )));
7759            }
7760            self.advance();
7761            let alias_ident = self.parse_optional_alias();
7762            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7763            return Ok(TableRef {
7764                name,
7765                alias: alias_ident,
7766                as_of_segment: None,
7767                unnest_expr: None,
7768                unnest_column_aliases: Vec::new(),
7769                generate_series_args: None,
7770                lateral_subquery: Some(Box::new(inner)),
7771                jsonb_each_text_arg: None,
7772            });
7773        }
7774        // v7.37.43-T4.5 — `jsonb_each_text(<expr>)` set-returning
7775        // function as a FROM item. Emits one row per (key, value)
7776        // pair in the JSONB object argument as TEXT columns. May
7777        // be wrapped in CROSS JOIN LATERAL when the argument
7778        // references a preceding FROM item (sentori migration
7779        // 0067 backfill shape: `CROSS JOIN LATERAL
7780        // jsonb_each_text(t.json_col) AS kv(key, value)`).
7781        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("jsonb_each_text"))
7782            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7783        {
7784            self.advance(); // jsonb_each_text
7785            self.advance(); // (
7786            let arg = self.parse_expr(0)?;
7787            if !matches!(self.peek(), Token::RParen) {
7788                return Err(self.err(alloc::format!(
7789                    "expected ')' after jsonb_each_text() argument, got {:?}",
7790                    self.peek()
7791                )));
7792            }
7793            self.advance();
7794            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7795            let name = alias_ident
7796                .clone()
7797                .unwrap_or_else(|| "jsonb_each_text".to_string());
7798            return Ok(TableRef {
7799                name,
7800                alias: alias_ident,
7801                as_of_segment: None,
7802                unnest_expr: None,
7803                unnest_column_aliases: Vec::new(),
7804                generate_series_args: None,
7805                lateral_subquery: None,
7806                jsonb_each_text_arg: Some(Box::new(arg)),
7807            });
7808        }
7809        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
7810        // source. Detect at the head before the bare-ident fallback;
7811        // unnest is not a reserved token.
7812        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
7813            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7814        {
7815            self.advance(); // unnest
7816            self.advance(); // (
7817            let expr = self.parse_expr(0)?;
7818            if !matches!(self.peek(), Token::RParen) {
7819                return Err(self.err(alloc::format!(
7820                    "expected ')' after unnest() argument, got {:?}",
7821                    self.peek()
7822                )));
7823            }
7824            self.advance();
7825            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
7826            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
7827            return Ok(TableRef {
7828                name,
7829                alias: alias_ident,
7830                as_of_segment: None,
7831                unnest_expr: Some(Box::new(expr)),
7832                unnest_column_aliases,
7833                generate_series_args: None,
7834                lateral_subquery: None,
7835                jsonb_each_text_arg: None,
7836            });
7837        }
7838        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7839        // [, step])` set-returning source. Same shape as unnest:
7840        // detect at the head, parse the comma-separated arg list,
7841        // dispatch downstream through the engine's set-returning
7842        // path. Supports integer triplets (mailrs's `WITH row_no AS
7843        // (SELECT * FROM generate_series(1, N))` pattern) and
7844        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
7845        // date-range iteration pattern, which pre-3.10 had no
7846        // direct equivalent in SPG).
7847        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
7848            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7849        {
7850            self.advance(); // generate_series
7851            self.advance(); // (
7852            let mut args: Vec<Expr> = Vec::new();
7853            loop {
7854                args.push(self.parse_expr(0)?);
7855                if matches!(self.peek(), Token::Comma) {
7856                    self.advance();
7857                    continue;
7858                }
7859                break;
7860            }
7861            if !matches!(self.peek(), Token::RParen) {
7862                return Err(self.err(alloc::format!(
7863                    "expected ')' after generate_series() arguments, got {:?}",
7864                    self.peek()
7865                )));
7866            }
7867            self.advance();
7868            if args.len() < 2 || args.len() > 3 {
7869                return Err(self.err(alloc::format!(
7870                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
7871                    args.len()
7872                )));
7873            }
7874            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7875            let name = alias_ident
7876                .clone()
7877                .unwrap_or_else(|| "generate_series".to_string());
7878            return Ok(TableRef {
7879                name,
7880                alias: alias_ident,
7881                as_of_segment: None,
7882                unnest_expr: None,
7883                unnest_column_aliases: Vec::new(),
7884                generate_series_args: Some(args),
7885                lateral_subquery: None,
7886                jsonb_each_text_arg: None,
7887            });
7888        }
7889        // v7.16.2 — preserve information_schema / pg_catalog
7890        // qualifiers (mailrs round-10 A.3). The generic
7891        // `expect_ident_like` strip silently drops the schema;
7892        // we want the engine to recognise these PG meta tables
7893        // and synthesise rows from the live catalog. Produce a
7894        // synthetic name (`__spg_info_columns` etc.) so the
7895        // engine's SELECT-side router can dispatch without
7896        // clashing with any user-defined `columns` table.
7897        let name = if let Some(synth) = self.try_peek_meta_qualified() {
7898            synth
7899        } else if let Some(synth) = self.try_peek_meta_bare() {
7900            synth
7901        } else {
7902            self.expect_ident_like()?
7903        };
7904        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
7905        // time-travel clause. Parse BEFORE the alias so the
7906        // alias can still ride at the tail (`tbl AS OF SEGMENT
7907        // '5' alias`). `AS` is a reserved keyword token, while
7908        // `OF` and `SEGMENT` are bare idents.
7909        let as_of_segment = if matches!(self.peek(), Token::As)
7910            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
7911        {
7912            self.advance(); // AS
7913            self.advance(); // OF
7914            let kw = match self.peek().clone() {
7915                Token::Ident(s) | Token::QuotedIdent(s) => s,
7916                other => {
7917                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
7918                }
7919            };
7920            if !kw.eq_ignore_ascii_case("segment") {
7921                return Err(self.err(format!(
7922                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
7923                )));
7924            }
7925            self.advance();
7926            // Segment id literal — accept either a string or
7927            // integer for operator ergonomics.
7928            let id = match self.advance() {
7929                Token::String(s) => s
7930                    .parse::<u32>()
7931                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7932                Token::Integer(n) => u32::try_from(n)
7933                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7934                other => {
7935                    return Err(self.err(format!(
7936                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
7937                    )));
7938                }
7939            };
7940            Some(id)
7941        } else {
7942            None
7943        };
7944        let alias = self.parse_optional_alias();
7945        Ok(TableRef {
7946            name,
7947            alias,
7948            as_of_segment,
7949            unnest_expr: None,
7950            unnest_column_aliases: Vec::new(),
7951            generate_series_args: None,
7952            lateral_subquery: None,
7953            jsonb_each_text_arg: None,
7954        })
7955    }
7956
7957    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
7958    /// but also accepts `AS alias(col [, col, …])` — the
7959    /// PG-standard table-function column-list form. The column
7960    /// list is only honoured when paired with `UNNEST(...)` in
7961    /// the parent; other call sites currently discard it.
7962    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
7963        let alias = self.parse_optional_alias();
7964        if alias.is_none() {
7965            return (None, Vec::new());
7966        }
7967        let mut cols: Vec<String> = Vec::new();
7968        if matches!(self.peek(), Token::LParen) {
7969            self.advance();
7970            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
7971                self.advance();
7972                cols.push(s);
7973                if matches!(self.peek(), Token::Comma) {
7974                    self.advance();
7975                    continue;
7976                }
7977                break;
7978            }
7979            if matches!(self.peek(), Token::RParen) {
7980                self.advance();
7981            }
7982        }
7983        (alias, cols)
7984    }
7985
7986    /// FROM-clause: a primary table reference plus zero-or-more joined
7987    /// peers expressed via either `, <table>` (cross-product, no ON) or
7988    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
7989    /// the join list flat (left-associative nested-loop semantics).
7990    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
7991        let primary = self.parse_table_ref()?;
7992        let mut joins = Vec::new();
7993        loop {
7994            // `, <table>` — cross-product with no ON.
7995            if matches!(self.peek(), Token::Comma) {
7996                self.advance();
7997                let table = self.parse_table_ref()?;
7998                joins.push(FromJoin {
7999                    kind: JoinKind::Cross,
8000                    table,
8001                    on: None,
8002                });
8003                continue;
8004            }
8005            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
8006            // CROSS JOIN, and bare JOIN (defaults to INNER).
8007            let kind =
8008                match self.peek() {
8009                    Token::Inner => {
8010                        self.advance();
8011                        if !matches!(self.peek(), Token::Join) {
8012                            return Err(self
8013                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
8014                        }
8015                        self.advance();
8016                        JoinKind::Inner
8017                    }
8018                    Token::Left => {
8019                        self.advance();
8020                        if matches!(self.peek(), Token::Outer) {
8021                            self.advance();
8022                        }
8023                        if !matches!(self.peek(), Token::Join) {
8024                            return Err(self.err(format!(
8025                                "expected JOIN after LEFT [OUTER], got {:?}",
8026                                self.peek()
8027                            )));
8028                        }
8029                        self.advance();
8030                        JoinKind::Left
8031                    }
8032                    Token::Cross => {
8033                        self.advance();
8034                        if !matches!(self.peek(), Token::Join) {
8035                            return Err(self
8036                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
8037                        }
8038                        self.advance();
8039                        JoinKind::Cross
8040                    }
8041                    Token::Join => {
8042                        self.advance();
8043                        JoinKind::Inner
8044                    }
8045                    _ => break,
8046                };
8047            let table = self.parse_table_ref()?;
8048            let on = if matches!(self.peek(), Token::On) {
8049                self.advance();
8050                Some(self.parse_expr(0)?)
8051            } else if kind == JoinKind::Cross {
8052                None
8053            } else {
8054                return Err(self.err(format!(
8055                    "expected ON after {:?} JOIN, got {:?}",
8056                    kind,
8057                    self.peek()
8058                )));
8059            };
8060            joins.push(FromJoin { kind, table, on });
8061        }
8062        Ok(FromClause { primary, joins })
8063    }
8064
8065    /// Optional alias after an expression or table:
8066    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
8067    /// accepted (PG-style implicit alias). Returns `None` if the next token
8068    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
8069    fn parse_optional_alias(&mut self) -> Option<String> {
8070        if matches!(self.peek(), Token::As) {
8071            self.advance();
8072            // After AS, the next token MUST be an identifier-like — if not,
8073            // we still return None and let the caller surface the error on the
8074            // next expectation. v0.2 keeps the alias path forgiving; the
8075            // corpus tests don't exercise the malformed case.
8076            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
8077                return self.expect_ident_like().ok();
8078            }
8079            return None;
8080        }
8081        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
8082        // grammar reserves a long list of follow-keywords from the
8083        // alias slot. SPG's bareword approximation: skip a small
8084        // set of idents that would otherwise be swallowed as the
8085        // table alias and break trailing clauses like CREATE
8086        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
8087        // CONFLICT WHERE shapes.
8088        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8089            if is_alias_stopword(s) {
8090                return None;
8091            }
8092            return self.expect_ident_like().ok();
8093        }
8094        None
8095    }
8096
8097    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
8098    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8099        // v7.30.2 (mailrs round-25 ask 2) — nesting budget: a parse
8100        // error beats a stack overflow (an overflow aborts the
8101        // embedding host process).
8102        self.enter_nested()?;
8103        let r = self.parse_expr_inner(min_prec);
8104        self.nest_depth -= 1;
8105        r
8106    }
8107
8108    fn parse_expr_inner(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
8109        let mut lhs = self.parse_unary()?;
8110        let mut chain_len = 0usize;
8111        while let Some((op, prec)) = binop_from(self.peek()) {
8112            if prec < min_prec {
8113                break;
8114            }
8115            // v7.30.2 (mailrs round-25 ask 2) — the chain builds
8116            // iteratively but evaluates and drops recursively;
8117            // depth beyond the budget overflows worker stacks.
8118            chain_len += 1;
8119            if chain_len > MAX_BINARY_CHAIN {
8120                return Err(self.err(alloc::format!(
8121                    "more than {MAX_BINARY_CHAIN} chained binary operators; rewrite long OR-equality chains as IN (…)"
8122                )));
8123            }
8124            self.advance();
8125            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
8126            // ANY is a bare ident; ALL is a reserved Token. Both
8127            // require an immediate `(` to disambiguate from
8128            // identifier columns named `any` / `all`.
8129            let any_kind = match self.peek() {
8130                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
8131                    Some(false)
8132                }
8133                Token::Ident(s) | Token::QuotedIdent(s)
8134                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
8135                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
8136                {
8137                    Some(s.eq_ignore_ascii_case("any"))
8138                }
8139                _ => None,
8140            };
8141            if let Some(is_any) = any_kind {
8142                self.advance(); // ident
8143                self.advance(); // (
8144                let arr = self.parse_expr(0)?;
8145                if !matches!(self.peek(), Token::RParen) {
8146                    return Err(self.err(alloc::format!(
8147                        "expected ')' after ANY/ALL argument, got {:?}",
8148                        self.peek()
8149                    )));
8150                }
8151                self.advance();
8152                lhs = Expr::AnyAll {
8153                    expr: Box::new(lhs),
8154                    op,
8155                    array: Box::new(arr),
8156                    is_any,
8157                };
8158                continue;
8159            }
8160            let rhs = self.parse_expr(prec + 1)?;
8161            lhs = Expr::Binary {
8162                lhs: Box::new(lhs),
8163                op,
8164                rhs: Box::new(rhs),
8165            };
8166        }
8167        Ok(lhs)
8168    }
8169
8170    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
8171        match self.peek() {
8172            Token::Not => {
8173                self.advance();
8174                // NOT sits between AND (2) and comparisons (4) — bind everything
8175                // ≥3, which leaves AND/OR outside.
8176                let e = self.parse_expr(3)?;
8177                Ok(Expr::Unary {
8178                    op: UnOp::Not,
8179                    expr: Box::new(e),
8180                })
8181            }
8182            Token::Minus => {
8183                self.advance();
8184                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
8185                // `<->` slotted into 5 and arithmetic shifted up).
8186                let e = self.parse_expr(8)?;
8187                Ok(Expr::Unary {
8188                    op: UnOp::Neg,
8189                    expr: Box::new(e),
8190                })
8191            }
8192            Token::Tilde => {
8193                self.advance();
8194                // Bitwise NOT binds like unary minus.
8195                let e = self.parse_expr(8)?;
8196                Ok(Expr::Unary {
8197                    op: UnOp::BitNot,
8198                    expr: Box::new(e),
8199                })
8200            }
8201            _ => self.parse_atom(),
8202        }
8203    }
8204
8205    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
8206        let tok_pos = self.pos;
8207        match self.advance() {
8208            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
8209            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
8210            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
8211            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
8212            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
8213            Token::Null => Ok(Expr::Literal(Literal::Null)),
8214            // v6.1.1 — `$N` placeholder. The actual Value lookup
8215            // happens in the engine eval path against the prepared-
8216            // statement bind buffer.
8217            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
8218            Token::LParen => {
8219                // v4.10: `(SELECT ...)` in expression position is a
8220                // scalar subquery; otherwise it's a parenthesised
8221                // expression. Peek for SELECT keyword to dispatch.
8222                if matches!(self.peek(), Token::Select) {
8223                    let inner = self.parse_select_stmt()?;
8224                    match self.advance() {
8225                        Token::RParen => {
8226                            let Statement::Select(s) = inner else {
8227                                unreachable!("parse_select_stmt returns Select")
8228                            };
8229                            Ok(Expr::ScalarSubquery(Box::new(s)))
8230                        }
8231                        other => Err(ParseError {
8232                            message: format!("expected ')' after scalar subquery, got {other:?}"),
8233                            token_pos: self.pos.saturating_sub(1),
8234                        }),
8235                    }
8236                } else {
8237                    let e = self.parse_expr(0)?;
8238                    match self.advance() {
8239                        Token::RParen => Ok(e),
8240                        other => Err(ParseError {
8241                            message: format!("expected ')', got {other:?}"),
8242                            token_pos: self.pos.saturating_sub(1),
8243                        }),
8244                    }
8245                }
8246            }
8247            Token::LBracket => self.parse_vector_literal_body(),
8248            Token::Extract => self.parse_extract_atom(),
8249            Token::Interval => self.parse_interval_atom(),
8250            // `LEFT` is a reserved-keyword token because the
8251            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
8252            // When `left` is followed by `(` we're in expression
8253            // position calling the PG `left(string, n)` function;
8254            // rebuild the AST as a regular function call so the
8255            // engine's apply_function dispatch picks it up.
8256            Token::Left if matches!(self.peek(), Token::LParen) => {
8257                self.advance(); // (
8258                let mut args = Vec::new();
8259                if !matches!(self.peek(), Token::RParen) {
8260                    loop {
8261                        args.push(self.parse_expr(0)?);
8262                        match self.peek() {
8263                            Token::Comma => {
8264                                self.advance();
8265                            }
8266                            Token::RParen => break,
8267                            other => {
8268                                return Err(self.err(alloc::format!(
8269                                    "expected ',' or ')' in left() args, got {other:?}"
8270                                )));
8271                            }
8272                        }
8273                    }
8274                }
8275                self.advance(); // )
8276                Ok(Expr::FunctionCall {
8277                    name: "left".into(),
8278                    args,
8279                })
8280            }
8281            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
8282            // token; we match on the bare ident. NOT is a token
8283            // (consumed in the comparison rung), but `EXISTS (...)`
8284            // at the top of an expression starts here.
8285            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
8286                self.parse_exists_atom(false)
8287            }
8288            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
8289            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
8290            // CASE is a bare ident; we dispatch on lowercase match.
8291            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
8292                self.parse_case_atom()
8293            }
8294            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
8295            // is not a reserved token; we match by case-insensitive
8296            // ident. The opening `[` must follow immediately.
8297            Token::Ident(s) | Token::QuotedIdent(s)
8298                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
8299            {
8300                self.advance(); // consume `[`
8301                let mut items: Vec<Expr> = Vec::new();
8302                if !matches!(self.peek(), Token::RBracket) {
8303                    loop {
8304                        items.push(self.parse_expr(0)?);
8305                        match self.peek() {
8306                            Token::Comma => {
8307                                self.advance();
8308                            }
8309                            Token::RBracket => break,
8310                            other => {
8311                                return Err(self.err(alloc::format!(
8312                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
8313                                )));
8314                            }
8315                        }
8316                    }
8317                }
8318                self.advance(); // consume `]`
8319                Ok(Expr::Array(items))
8320            }
8321            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
8322            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
8323            // We special-case before the generic ident dispatch so
8324            // the AGAINST clause never reaches the function-call
8325            // loop (which would mis-read `(cols) AGAINST` as a
8326            // call with no trailing modifier). The shape is
8327            // rewritten to a Boolean OR over per-column
8328            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8329            // term)` so the existing FTS evaluator handles
8330            // semantics — the fulltext-GIN built at CREATE TABLE
8331            // time is currently a "real index that survives dump
8332            // round-trip"; the planner hook that actually uses
8333            // it for posting-list intersection lands in a later
8334            // sub-phase (Phase 2.2b) without touching this surface.
8335            Token::Ident(s) | Token::QuotedIdent(s)
8336                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
8337            {
8338                self.parse_match_against_atom()
8339            }
8340            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
8341            // v7.37.43-T4 — PG-unreserved keywords are legal column /
8342            // alias names in expression context too. `release` appears
8343            // in sentori `0003_partition_events.sql` as both a column
8344            // reference (SELECT … release …) and an INSERT column list
8345            // entry. Mirrors `expect_ident_like`'s expansion of the
8346            // identifier set.
8347            other if unreserved_keyword_text(&other).is_some() => {
8348                let s = unreserved_keyword_text(&other).unwrap();
8349                self.finish_ident_atom(s)
8350            }
8351            other => Err(ParseError {
8352                message: format!("unexpected token {other:?} in expression"),
8353                token_pos: tok_pos,
8354            }),
8355        }
8356        // After parsing the atom, fold any postfix `::vector` casts.
8357        .and_then(|atom| self.finish_postfix_casts(atom))
8358    }
8359
8360    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
8361    /// Both bind tighter than any binary op.
8362    /// Shared cast-target parser for postfix `::TYPE` and the
8363    /// standard `CAST(expr AS TYPE)` form (v7.25, round-17).
8364    fn parse_cast_target(&mut self) -> Result<CastTarget, ParseError> {
8365        let target = match self.advance() {
8366            Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
8367                "int" | "integer" | "int4" => {
8368                    if matches!(self.peek(), Token::LBracket)
8369                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8370                    {
8371                        self.advance();
8372                        self.advance();
8373                        CastTarget::IntArray
8374                    } else {
8375                        CastTarget::Int
8376                    }
8377                }
8378                "bigint" | "int8" => {
8379                    if matches!(self.peek(), Token::LBracket)
8380                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8381                    {
8382                        self.advance();
8383                        self.advance();
8384                        CastTarget::BigIntArray
8385                    } else {
8386                        CastTarget::BigInt
8387                    }
8388                }
8389                "float" | "double" | "real" => CastTarget::Float,
8390                "text" => {
8391                    // v7.10.11 — `::TEXT[]` widens to TextArray.
8392                    if matches!(self.peek(), Token::LBracket)
8393                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8394                    {
8395                        self.advance();
8396                        self.advance();
8397                        CastTarget::TextArray
8398                    } else {
8399                        CastTarget::Text
8400                    }
8401                }
8402                "bool" | "boolean" => CastTarget::Bool,
8403                "vector" => CastTarget::Vector,
8404                "date" => CastTarget::Date,
8405                "timestamp" | "datetime" => CastTarget::Timestamp,
8406                "timestamptz" => CastTarget::Timestamptz,
8407                "interval" => CastTarget::Interval,
8408                "json" => CastTarget::Json,
8409                "jsonb" => CastTarget::Jsonb,
8410                "regtype" => CastTarget::RegType,
8411                "regclass" => CastTarget::RegClass,
8412                // v7.12.0 — `::tsvector` / `::tsquery`.
8413                // Engine decodes the LHS text via the PG
8414                // external form parser.
8415                "tsvector" => CastTarget::TsVector,
8416                "tsquery" => CastTarget::TsQuery,
8417                // v7.17.0 — `::uuid`. Engine decodes the LHS
8418                // text via `spg_storage::parse_uuid_str`.
8419                "uuid" => CastTarget::Uuid,
8420                // v7.18 — `::bytea`. Engine decodes the LHS
8421                // text via the PG hex form (`'\xdeadbeef'`)
8422                // or escape form (`'\\x05\\x00'`). Closes
8423                // mailrs D-pre #3 reverse-acceptance gap.
8424                "bytea" => CastTarget::Bytea,
8425                // v7.37.5 ship triage — generic typed-cast escape.
8426                // Anything the long-tail PG type ident table knows
8427                // about(network/bit/geometry/multirange/etc.)flows
8428                // through `CastTarget::Named(canonical)`; the engine
8429                // resolves via `column_type_to_data_type` and dispatches
8430                // through the typed `coerce_value` path. Truly
8431                // unrecognised idents still hit the error arm below
8432                // because the engine rejects them.
8433                other => {
8434                    // Optional `(N[, M])` precision args — `::numeric(10,2)`,
8435                    // `::varchar(255)`, etc. Capture into the canonical
8436                    // `name(p,s)` form so `type_name_to_data_type` can
8437                    // reconstruct the `DataType::Numeric { precision,
8438                    // scale }` (and similar param-carrying types).
8439                    let mut name = other.to_string();
8440                    if matches!(self.peek(), Token::LParen) {
8441                        let mut buf = alloc::string::String::from("(");
8442                        let mut depth = 0usize;
8443                        loop {
8444                            match self.advance() {
8445                                Token::LParen => {
8446                                    depth += 1;
8447                                    if depth > 1 {
8448                                        buf.push('(');
8449                                    }
8450                                }
8451                                Token::RParen => {
8452                                    depth -= 1;
8453                                    if depth == 0 {
8454                                        buf.push(')');
8455                                        break;
8456                                    }
8457                                    buf.push(')');
8458                                }
8459                                Token::Comma => buf.push(','),
8460                                Token::Integer(n) => buf.push_str(&alloc::format!("{n}")),
8461                                Token::Eof => break,
8462                                _ => {}
8463                            }
8464                        }
8465                        name.push_str(&buf);
8466                    }
8467                    // Optional postfix `[]` widens to the array form —
8468                    // `::BOOL[]`, `::NUMERIC[]`, `::SMALLINT[]`, etc.
8469                    // The engine's `type_name_to_data_type` recognises
8470                    // the canonical `<ty>_array` form.
8471                    if matches!(self.peek(), Token::LBracket)
8472                        && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8473                    {
8474                        self.advance();
8475                        self.advance();
8476                        name.push_str("_array");
8477                    }
8478                    CastTarget::Named(name)
8479                }
8480            },
8481            Token::Interval => CastTarget::Interval,
8482            other => {
8483                return Err(ParseError {
8484                    message: format!("expected type ident after `::`, got {other:?}"),
8485                    token_pos: self.pos.saturating_sub(1),
8486                });
8487            }
8488        };
8489        // v7.37.5 ship triage — postfix `[]` widens a scalar cast
8490        // target to its array sibling. Closed-enum arms (Bool /
8491        // SmallInt / Numeric / Float / Date / …) didn't carry the
8492        // explicit widening that Text / Int / BigInt did, so
8493        // `::BOOL[]` / `::NUMERIC[]` etc. surfaced as a parse
8494        // error. The widening here mirrors the per-arm Text /
8495        // Int / BigInt logic above + folds the new ζ-A first-class
8496        // types through `CastTarget::Named("<ty>_array")`.
8497        if matches!(self.peek(), Token::LBracket)
8498            && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
8499        {
8500            let widened = match &target {
8501                CastTarget::Bool => Some(CastTarget::Named("bool_array".to_string())),
8502                CastTarget::Date => Some(CastTarget::Named("date_array".to_string())),
8503                CastTarget::Timestamp | CastTarget::Timestamptz => {
8504                    Some(CastTarget::Named("timestamptz_array".to_string()))
8505                }
8506                CastTarget::Uuid => Some(CastTarget::Named("uuid_array".to_string())),
8507                CastTarget::Json | CastTarget::Jsonb => {
8508                    Some(CastTarget::Named("jsonb_array".to_string()))
8509                }
8510                CastTarget::Bytea => Some(CastTarget::Named("bytea_array".to_string())),
8511                CastTarget::Interval => Some(CastTarget::Named("interval_array".to_string())),
8512                CastTarget::Float => Some(CastTarget::Named("float_array".to_string())),
8513                CastTarget::Named(name) => {
8514                    let mut a = name.clone();
8515                    a.push_str("_array");
8516                    Some(CastTarget::Named(a))
8517                }
8518                // Int / BigInt / Text / Vector / TsVector / TsQuery /
8519                // RegType / RegClass / TextArray / IntArray /
8520                // BigIntArray already finalised — leave as is.
8521                _ => None,
8522            };
8523            if let Some(w) = widened {
8524                self.advance();
8525                self.advance();
8526                return Ok(w);
8527            }
8528        }
8529        Ok(target)
8530    }
8531
8532    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
8533        loop {
8534            if matches!(self.peek(), Token::DoubleColon) {
8535                self.advance();
8536                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
8537                // target set to include INTERVAL (reserved Token),
8538                // TIMESTAMPTZ, and PG catalog regtype / regclass.
8539                // mailrs follow-up H3a + H3b.
8540                let target = self.parse_cast_target()?;
8541                expr = Expr::Cast {
8542                    expr: Box::new(expr),
8543                    target,
8544                };
8545                continue;
8546            }
8547            if matches!(self.peek(), Token::Is) {
8548                self.advance();
8549                let negated = if matches!(self.peek(), Token::Not) {
8550                    self.advance();
8551                    true
8552                } else {
8553                    false
8554                };
8555                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
8556                // mailrs pg_dump.
8557                if matches!(self.peek(), Token::Distinct) {
8558                    self.advance();
8559                    if !matches!(self.peek(), Token::From) {
8560                        return Err(self.err(format!(
8561                            "expected FROM after IS{} DISTINCT, got {:?}",
8562                            if negated { " NOT" } else { "" },
8563                            self.peek()
8564                        )));
8565                    }
8566                    self.advance();
8567                    // Right-hand side: parse at the same precedence
8568                    // tier as comparison so `x IS DISTINCT FROM a + b`
8569                    // groups as `x IS DISTINCT FROM (a + b)`.
8570                    let rhs = self.parse_expr(20)?;
8571                    let op = if negated {
8572                        BinOp::IsNotDistinctFrom
8573                    } else {
8574                        BinOp::IsDistinctFrom
8575                    };
8576                    expr = Expr::Binary {
8577                        op,
8578                        lhs: Box::new(expr),
8579                        rhs: Box::new(rhs),
8580                    };
8581                    continue;
8582                }
8583                if !matches!(self.peek(), Token::Null) {
8584                    return Err(self.err(format!(
8585                        "expected NULL or DISTINCT after IS{}, got {:?}",
8586                        if negated { " NOT" } else { "" },
8587                        self.peek()
8588                    )));
8589                }
8590                self.advance();
8591                expr = Expr::IsNull {
8592                    expr: Box::new(expr),
8593                    negated,
8594                };
8595                continue;
8596            }
8597            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
8598            // Look one token ahead so a stray `NOT` not followed by any of
8599            // these flows through to the early return below untouched.
8600            let negated = if matches!(self.peek(), Token::Not) {
8601                let next = self.tokens.get(self.pos + 1);
8602                matches!(next, Some(Token::Between | Token::In | Token::Like))
8603                    || matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("ilike"))
8604            } else {
8605                false
8606            };
8607            if negated {
8608                self.advance();
8609            }
8610            if matches!(self.peek(), Token::Between) {
8611                expr = self.parse_between_tail(expr, negated)?;
8612                continue;
8613            }
8614            if matches!(self.peek(), Token::In) {
8615                expr = self.parse_in_tail(expr, negated)?;
8616                continue;
8617            }
8618            if matches!(self.peek(), Token::Like) {
8619                self.advance();
8620                // Pattern at the same precedence as other comparison RHSes —
8621                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
8622                let pattern = self.parse_expr(5)?;
8623                expr = Expr::Like {
8624                    expr: Box::new(expr),
8625                    pattern: Box::new(pattern),
8626                    negated,
8627                    case_insensitive: false,
8628                };
8629                continue;
8630            }
8631            // v7.25 (round-17) — ILIKE: case-insensitive LIKE. The
8632            // keyword reaches us as a plain identifier.
8633            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("ilike")) {
8634                self.advance();
8635                let pattern = self.parse_expr(5)?;
8636                expr = Expr::Like {
8637                    expr: Box::new(expr),
8638                    pattern: Box::new(pattern),
8639                    negated,
8640                    case_insensitive: true,
8641                };
8642                continue;
8643            }
8644            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
8645            // returns NULL for out-of-range. Multiple subscripts
8646            // chain: `a[i][j]` parses left-to-right.
8647            if matches!(self.peek(), Token::LBracket) {
8648                self.advance();
8649                let index = self.parse_expr(0)?;
8650                if !matches!(self.peek(), Token::RBracket) {
8651                    return Err(self.err(alloc::format!(
8652                        "expected ']' after array index, got {:?}",
8653                        self.peek()
8654                    )));
8655                }
8656                self.advance();
8657                expr = Expr::ArraySubscript {
8658                    target: Box::new(expr),
8659                    index: Box::new(index),
8660                };
8661                continue;
8662            }
8663            return Ok(expr);
8664        }
8665    }
8666
8667    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
8668    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
8669    /// `AND` is not swallowed.
8670    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8671        self.advance(); // BETWEEN
8672        let low = self.parse_expr(5)?;
8673        if !matches!(self.peek(), Token::And) {
8674            return Err(self.err(format!(
8675                "expected AND after BETWEEN low bound, got {:?}",
8676                self.peek()
8677            )));
8678        }
8679        self.advance();
8680        let high = self.parse_expr(5)?;
8681        let target = Box::new(expr);
8682        let combined = Expr::Binary {
8683            lhs: Box::new(Expr::Binary {
8684                lhs: target.clone(),
8685                op: BinOp::GtEq,
8686                rhs: Box::new(low),
8687            }),
8688            op: BinOp::And,
8689            rhs: Box::new(Expr::Binary {
8690                lhs: target,
8691                op: BinOp::LtEq,
8692                rhs: Box::new(high),
8693            }),
8694        };
8695        Ok(maybe_not(combined, negated))
8696    }
8697
8698    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
8699    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
8700    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
8701    /// Caller already consumed the leading `WITH` ident.
8702    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
8703        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
8704        // Comes through as an identifier; consume it if present and
8705        // mark every CTE in the clause as recursive (PG semantics —
8706        // the flag is per-WITH, not per-CTE).
8707        let mut recursive = false;
8708        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8709            && s.eq_ignore_ascii_case("recursive")
8710        {
8711            self.advance();
8712            recursive = true;
8713        }
8714        let mut ctes = Vec::new();
8715        loop {
8716            let name = self.expect_ident_like()?;
8717            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
8718            // PG uses these to rename the body's output columns; we
8719            // do the same below by overriding `columns[i].name`.
8720            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
8721                self.advance();
8722                let mut names = Vec::new();
8723                loop {
8724                    names.push(self.expect_ident_like()?);
8725                    if matches!(self.peek(), Token::Comma) {
8726                        self.advance();
8727                        continue;
8728                    }
8729                    break;
8730                }
8731                if !matches!(self.peek(), Token::RParen) {
8732                    return Err(self.err(format!(
8733                        "expected ')' to close CTE column list, got {:?}",
8734                        self.peek()
8735                    )));
8736                }
8737                self.advance();
8738                names
8739            } else {
8740                Vec::new()
8741            };
8742            // AS is a reserved Token::As (used by SELECT-item / FROM
8743            // aliasing) — handle it specially rather than as a bare
8744            // ident.
8745            if !matches!(self.peek(), Token::As) {
8746                return Err(self.err(format!(
8747                    "expected AS after CTE name {name:?}, got {:?}",
8748                    self.peek()
8749                )));
8750            }
8751            self.advance();
8752            if !matches!(self.peek(), Token::LParen) {
8753                return Err(self.err(format!(
8754                    "expected '(' after AS in WITH clause, got {:?}",
8755                    self.peek()
8756                )));
8757            }
8758            self.advance();
8759            // v7.37.43-T4.4 — accept INSERT / UPDATE / DELETE (with
8760            // RETURNING) as the CTE body in addition to SELECT.
8761            // PG writable CTE semantics. UPDATE / DELETE come in as
8762            // bare Idents (lexer keeps SELECT / INSERT as reserved
8763            // tokens but treats the rest of DML as case-insensitive
8764            // idents).
8765            let is_update_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
8766            let is_delete_kw = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
8767            let body = match self.peek() {
8768                Token::Select => {
8769                    let inner = self.parse_select_stmt()?;
8770                    let Statement::Select(s) = inner else {
8771                        unreachable!("parse_select_stmt returns Select");
8772                    };
8773                    crate::ast::CteBody::Select(s)
8774                }
8775                Token::Insert => {
8776                    let inner = self.parse_one_statement()?;
8777                    let Statement::Insert(s) = inner else {
8778                        unreachable!("Token::Insert routes to Insert");
8779                    };
8780                    crate::ast::CteBody::Insert(alloc::boxed::Box::new(s))
8781                }
8782                _ if is_update_kw => {
8783                    let inner = self.parse_one_statement()?;
8784                    let Statement::Update(s) = inner else {
8785                        return Err(
8786                            self.err(format!("expected UPDATE inside WITH (…), got {inner:?}"))
8787                        );
8788                    };
8789                    crate::ast::CteBody::Update(alloc::boxed::Box::new(s))
8790                }
8791                _ if is_delete_kw => {
8792                    let inner = self.parse_one_statement()?;
8793                    let Statement::Delete(s) = inner else {
8794                        return Err(
8795                            self.err(format!("expected DELETE inside WITH (…), got {inner:?}"))
8796                        );
8797                    };
8798                    crate::ast::CteBody::Delete(alloc::boxed::Box::new(s))
8799                }
8800                other => {
8801                    return Err(self.err(format!(
8802                        "WITH body must be SELECT / INSERT / UPDATE / DELETE, got {other:?}"
8803                    )));
8804                }
8805            };
8806            if !matches!(self.peek(), Token::RParen) {
8807                return Err(self.err(format!(
8808                    "expected ')' after CTE body, got {:?}",
8809                    self.peek()
8810                )));
8811            }
8812            self.advance();
8813            ctes.push(crate::ast::Cte {
8814                name,
8815                body,
8816                recursive,
8817                column_overrides,
8818            });
8819            if matches!(self.peek(), Token::Comma) {
8820                self.advance();
8821                continue;
8822            }
8823            break;
8824        }
8825        // v7.37.43-T4.4 — the outer body may be SELECT (classical),
8826        // or INSERT / UPDATE / DELETE (writable CTE outer). Attach
8827        // the parsed CTEs to whichever statement the body produces.
8828        let outer_is_update = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update"));
8829        let outer_is_delete = matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete"));
8830        match self.peek() {
8831            Token::Select => {
8832                let body_stmt = self.parse_select_stmt()?;
8833                let Statement::Select(mut body) = body_stmt else {
8834                    unreachable!()
8835                };
8836                body.ctes = ctes;
8837                Ok(Statement::Select(body))
8838            }
8839            Token::Insert => {
8840                let body_stmt = self.parse_one_statement()?;
8841                let Statement::Insert(mut body) = body_stmt else {
8842                    unreachable!()
8843                };
8844                body.ctes = ctes;
8845                Ok(Statement::Insert(body))
8846            }
8847            _ if outer_is_update => {
8848                let body_stmt = self.parse_one_statement()?;
8849                let Statement::Update(mut body) = body_stmt else {
8850                    return Err(self.err(format!("expected UPDATE after WITH clause")));
8851                };
8852                body.ctes = ctes;
8853                Ok(Statement::Update(body))
8854            }
8855            _ if outer_is_delete => {
8856                let body_stmt = self.parse_one_statement()?;
8857                let Statement::Delete(mut body) = body_stmt else {
8858                    return Err(self.err(format!("expected DELETE after WITH clause")));
8859                };
8860                body.ctes = ctes;
8861                Ok(Statement::Delete(body))
8862            }
8863            other => Err(self.err(format!(
8864                "expected SELECT / INSERT / UPDATE / DELETE after WITH clause, got {other:?}"
8865            ))),
8866        }
8867    }
8868
8869    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
8870    /// already consumed the leading `EXISTS` ident via
8871    /// `self.advance()`.
8872    /// v7.13.0 — parse the rest of a `CASE … END` expression after
8873    /// the leading `CASE` ident has been consumed (mailrs round-5
8874    /// G9). Supports both the searched form
8875    /// (`CASE WHEN cond THEN val …`) and the simple form
8876    /// (`CASE operand WHEN val THEN val …`).
8877    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
8878        // Disambiguate searched vs simple form: if the next token
8879        // is `WHEN`, we're in the searched form. Otherwise the
8880        // intervening expression is the operand.
8881        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
8882            None
8883        } else {
8884            Some(Box::new(self.parse_expr(0)?))
8885        };
8886        let mut branches: Vec<(Expr, Expr)> = Vec::new();
8887        loop {
8888            match self.peek() {
8889                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
8890                    self.advance();
8891                    let cond = self.parse_expr(0)?;
8892                    match self.peek() {
8893                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
8894                            self.advance();
8895                        }
8896                        other => {
8897                            return Err(self.err(alloc::format!(
8898                                "expected THEN after CASE WHEN <expr>, got {other:?}"
8899                            )));
8900                        }
8901                    }
8902                    let value = self.parse_expr(0)?;
8903                    branches.push((cond, value));
8904                }
8905                _ => break,
8906            }
8907        }
8908        if branches.is_empty() {
8909            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
8910        }
8911        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
8912        {
8913            self.advance();
8914            Some(Box::new(self.parse_expr(0)?))
8915        } else {
8916            None
8917        };
8918        match self.peek() {
8919            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
8920                self.advance();
8921            }
8922            other => {
8923                return Err(self.err(alloc::format!(
8924                    "expected END to close CASE expression, got {other:?}"
8925                )));
8926            }
8927        }
8928        Ok(Expr::Case {
8929            operand,
8930            branches,
8931            else_branch,
8932        })
8933    }
8934
8935    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
8936        if !matches!(self.peek(), Token::LParen) {
8937            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
8938        }
8939        self.advance();
8940        let inner = self.parse_select_stmt()?;
8941        if !matches!(self.peek(), Token::RParen) {
8942            return Err(self.err(format!(
8943                "expected ')' after EXISTS-subquery, got {:?}",
8944                self.peek()
8945            )));
8946        }
8947        self.advance();
8948        let Statement::Select(s) = inner else {
8949            unreachable!("parse_select_stmt returns Select")
8950        };
8951        Ok(Expr::Exists {
8952            subquery: Box::new(s),
8953            negated,
8954        })
8955    }
8956
8957    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8958        self.advance(); // IN
8959        if !matches!(self.peek(), Token::LParen) {
8960            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
8961        }
8962        self.advance();
8963        // v4.10: `IN (SELECT ...)` — subquery branch.
8964        if matches!(self.peek(), Token::Select) {
8965            let inner = self.parse_select_stmt()?;
8966            if !matches!(self.peek(), Token::RParen) {
8967                return Err(self.err(format!(
8968                    "expected ')' after IN-subquery, got {:?}",
8969                    self.peek()
8970                )));
8971            }
8972            self.advance();
8973            let Statement::Select(s) = inner else {
8974                unreachable!("parse_select_stmt always returns Statement::Select")
8975            };
8976            return Ok(Expr::InSubquery {
8977                expr: Box::new(expr),
8978                subquery: Box::new(s),
8979                negated,
8980            });
8981        }
8982        let mut elements = Vec::new();
8983        if !matches!(self.peek(), Token::RParen) {
8984            loop {
8985                elements.push(self.parse_expr(0)?);
8986                match self.peek() {
8987                    Token::Comma => {
8988                        self.advance();
8989                    }
8990                    Token::RParen => break,
8991                    other => {
8992                        return Err(
8993                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
8994                        );
8995                    }
8996                }
8997            }
8998        }
8999        self.advance(); // ')'
9000        // v7.30.2 (mailrs round-25) — flat InList node instead of a
9001        // left-deep OR-Eq chain: chain depth scaled with the element
9002        // count and overflowed the stack (eval + drop are recursive).
9003        if elements.is_empty() {
9004            return Ok(maybe_not(Expr::Literal(Literal::Bool(false)), negated));
9005        }
9006        Ok(Expr::InList {
9007            expr: Box::new(expr),
9008            list: elements,
9009            negated,
9010        })
9011    }
9012
9013    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
9014    /// already consumed by the caller. Elements must be numeric literals
9015    /// (with optional unary `-`); any compound expression is rejected at
9016    /// parse time so the runtime never needs to evaluate inside a vector.
9017    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
9018    /// has already consumed the `EXTRACT` token before calling us —
9019    /// we pick up at the opening `(`.
9020    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
9021    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
9022    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
9023    /// per-column OR-fold of
9024    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
9025    /// term)` so the existing FTS evaluator handles semantics.
9026    ///
9027    /// The mode modifier is accepted-and-ignored at v7.17 — all
9028    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
9029    /// mode operators (`+foo -bar`) would need their own parser
9030    /// (Phase 2.2c); customers who hit them today already get a
9031    /// correct lexeme-match against the bare term, only without
9032    /// the +/- precedence the customer asked for.
9033    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
9034        // Already at `MATCH`-consumed position; the dispatcher
9035        // confirmed the next token is `(`.
9036        if !matches!(self.peek(), Token::LParen) {
9037            return Err(self.err(alloc::format!(
9038                "expected '(' after MATCH, got {:?}",
9039                self.peek()
9040            )));
9041        }
9042        self.advance();
9043        let mut cols: Vec<Expr> = Vec::new();
9044        loop {
9045            cols.push(self.parse_expr(0)?);
9046            match self.peek() {
9047                Token::Comma => {
9048                    self.advance();
9049                }
9050                Token::RParen => break,
9051                other => {
9052                    return Err(self.err(alloc::format!(
9053                        "expected ',' or ')' in MATCH column list, got {other:?}"
9054                    )));
9055                }
9056            }
9057        }
9058        self.advance(); // ')'
9059        // Expect AGAINST.
9060        match self.peek() {
9061            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
9062                self.advance();
9063            }
9064            other => {
9065                return Err(self.err(alloc::format!(
9066                    "expected AGAINST after MATCH column list, got {other:?}"
9067                )));
9068            }
9069        }
9070        if !matches!(self.peek(), Token::LParen) {
9071            return Err(self.err(alloc::format!(
9072                "expected '(' after AGAINST, got {:?}",
9073                self.peek()
9074            )));
9075        }
9076        self.advance();
9077        // Read AGAINST's argument as a single primary token —
9078        // string literal, placeholder, or column-ref ident. We
9079        // can't call `parse_expr` / `parse_unary` here because
9080        // the postfix chain inside `parse_atom` would greedily
9081        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
9082        // and fail at "expected '(' after IN". Customers always
9083        // write a literal or bound parameter in AGAINST, so this
9084        // restriction is non-blocking; the error path explains
9085        // the limit if a more complex expression shows up.
9086        let term = match self.advance() {
9087            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
9088            Token::Placeholder(n) => Expr::Placeholder(n),
9089            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
9090                qualifier: None,
9091                name: s,
9092            }),
9093            other => {
9094                return Err(self.err(alloc::format!(
9095                    "MATCH ... AGAINST(<term>) expects a string literal, \
9096                     bound parameter, or column ref, got {other:?}"
9097                )));
9098            }
9099        };
9100        // Optional mode tail — accept-and-ignore at v7.17:
9101        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
9102        //   IN BOOLEAN MODE
9103        //   WITH QUERY EXPANSION
9104        loop {
9105            match self.peek() {
9106                // IN lexes as a reserved Token::In, not an ident,
9107                // so it gets its own arm.
9108                Token::In => {
9109                    self.advance();
9110                }
9111                Token::Ident(s) | Token::QuotedIdent(s)
9112                    if s.eq_ignore_ascii_case("natural")
9113                        || s.eq_ignore_ascii_case("language")
9114                        || s.eq_ignore_ascii_case("boolean")
9115                        || s.eq_ignore_ascii_case("mode")
9116                        || s.eq_ignore_ascii_case("with")
9117                        || s.eq_ignore_ascii_case("query")
9118                        || s.eq_ignore_ascii_case("expansion") =>
9119                {
9120                    self.advance();
9121                }
9122                _ => break,
9123            }
9124        }
9125        if !matches!(self.peek(), Token::RParen) {
9126            return Err(self.err(alloc::format!(
9127                "expected ')' to close AGAINST, got {:?}",
9128                self.peek()
9129            )));
9130        }
9131        self.advance();
9132        // Build per-column `to_tsvector('simple', col) @@
9133        // plainto_tsquery('simple', term)` and OR-fold.
9134        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
9135        let plainto = Expr::FunctionCall {
9136            name: String::from("plainto_tsquery"),
9137            args: alloc::vec![simple_lit(), term.clone()],
9138        };
9139        let mut folded: Option<Expr> = None;
9140        for col in cols {
9141            let to_tsv = Expr::FunctionCall {
9142                name: String::from("to_tsvector"),
9143                args: alloc::vec![simple_lit(), col],
9144            };
9145            let leaf = Expr::Binary {
9146                lhs: Box::new(to_tsv),
9147                op: crate::ast::BinOp::TsMatch,
9148                rhs: Box::new(plainto.clone()),
9149            };
9150            folded = Some(match folded {
9151                None => leaf,
9152                Some(prev) => Expr::Binary {
9153                    lhs: Box::new(prev),
9154                    op: crate::ast::BinOp::Or,
9155                    rhs: Box::new(leaf),
9156                },
9157            });
9158        }
9159        match folded {
9160            Some(e) => Ok(e),
9161            None => Err(self.err(String::from(
9162                "MATCH(...) AGAINST(...) requires at least one column",
9163            ))),
9164        }
9165    }
9166
9167    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
9168        if !matches!(self.peek(), Token::LParen) {
9169            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
9170        }
9171        self.advance();
9172        let field_name = self.expect_ident_like()?;
9173        let field = match field_name.to_ascii_lowercase().as_str() {
9174            "year" => ExtractField::Year,
9175            "month" => ExtractField::Month,
9176            "day" => ExtractField::Day,
9177            "hour" => ExtractField::Hour,
9178            "minute" => ExtractField::Minute,
9179            "second" => ExtractField::Second,
9180            "microsecond" | "microseconds" => ExtractField::Microsecond,
9181            "epoch" => ExtractField::Epoch,
9182            other => {
9183                return Err(self.err(format!(
9184                    "unknown EXTRACT field {other:?}; \
9185                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
9186                )));
9187            }
9188        };
9189        if !matches!(self.peek(), Token::From) {
9190            return Err(self.err(format!(
9191                "expected FROM after EXTRACT field, got {:?}",
9192                self.peek()
9193            )));
9194        }
9195        self.advance();
9196        let source = self.parse_expr(0)?;
9197        if !matches!(self.peek(), Token::RParen) {
9198            return Err(self.err(format!(
9199                "expected ')' to close EXTRACT, got {:?}",
9200                self.peek()
9201            )));
9202        }
9203        self.advance();
9204        Ok(Expr::Extract {
9205            field,
9206            source: Box::new(source),
9207        })
9208    }
9209
9210    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
9211    /// is already consumed; we expect a single string literal next and
9212    /// resolve it into `Literal::Interval` at parse time so the engine
9213    /// never has to re-tokenise inside the string.
9214    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
9215        let tok = self.advance();
9216        let Token::String(text) = tok else {
9217            return Err(self.err(format!(
9218                "expected string literal after INTERVAL, got {tok:?}"
9219            )));
9220        };
9221        let (months, days, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
9222            message: format!(
9223                "cannot parse INTERVAL {text:?}; \
9224                     expected `<n> <unit> [<n> <unit> ...]` with units \
9225                     microsecond[s], millisecond[s], second[s], minute[s], \
9226                     hour[s], day[s], week[s], month[s], year[s]"
9227            ),
9228            token_pos: self.pos.saturating_sub(1),
9229        })?;
9230        Ok(Expr::Literal(Literal::Interval {
9231            months,
9232            days,
9233            micros,
9234            text,
9235        }))
9236    }
9237
9238    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
9239        let mut elems = Vec::new();
9240        if matches!(self.peek(), Token::RBracket) {
9241            self.advance();
9242            return Ok(Expr::Literal(Literal::Vector(elems)));
9243        }
9244        loop {
9245            let e = self.parse_expr(0)?;
9246            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
9247                message: format!("vector element must be a numeric literal, got {e:?}"),
9248                token_pos: self.pos,
9249            })?;
9250            elems.push(x);
9251            match self.peek() {
9252                Token::Comma => {
9253                    self.advance();
9254                }
9255                Token::RBracket => {
9256                    self.advance();
9257                    break;
9258                }
9259                other => {
9260                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
9261                }
9262            }
9263        }
9264        Ok(Expr::Literal(Literal::Vector(elems)))
9265    }
9266
9267    /// Atom that started with an identifier: could be `t.col`, `col`, or
9268    /// `func(arg, ...)`. Detect each shape by looking at the next token.
9269    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
9270    /// [, ...])`. Caller has already consumed `OVER`. Either clause
9271    /// is optional; an empty `()` is also legal (PG semantics).
9272    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
9273    /// modifier between `name(args)` and `OVER (...)`. Default is
9274    /// `Respect`. Unrecognised idents leave the stream unchanged.
9275    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
9276        let Token::Ident(s) = self.peek().clone() else {
9277            return NullTreatment::Respect;
9278        };
9279        let is_ignore = s.eq_ignore_ascii_case("ignore");
9280        let is_respect = s.eq_ignore_ascii_case("respect");
9281        if !is_ignore && !is_respect {
9282            return NullTreatment::Respect;
9283        }
9284        // Lookahead for NULLS — only consume both tokens together.
9285        // pos+1 must hold a "nulls" ident.
9286        if self.pos + 1 < self.tokens.len()
9287            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
9288            && s2.eq_ignore_ascii_case("nulls")
9289        {
9290            self.advance();
9291            self.advance();
9292            return if is_ignore {
9293                NullTreatment::Ignore
9294            } else {
9295                NullTreatment::Respect
9296            };
9297        }
9298        NullTreatment::Respect
9299    }
9300
9301    /// v7.32 (mailrs round-29) — `agg(args) FILTER (WHERE cond)`.
9302    /// `FILTER` is an unreserved keyword, so it arrives as an `Ident`
9303    /// (same shape as the `OVER` tail). Consumes the whole clause and
9304    /// returns the predicate; returns `None` when no `FILTER` follows.
9305    fn parse_filter_clause(&mut self) -> Result<Option<Box<Expr>>, ParseError> {
9306        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9307            return Ok(None);
9308        };
9309        if !s.eq_ignore_ascii_case("filter") {
9310            return Ok(None);
9311        }
9312        self.advance(); // FILTER
9313        if !matches!(self.peek(), Token::LParen) {
9314            return Err(self.err(format!("expected '(' after FILTER, got {:?}", self.peek())));
9315        }
9316        self.advance(); // (
9317        if !matches!(self.peek(), Token::Where) {
9318            return Err(self.err(format!(
9319                "expected WHERE inside FILTER (...), got {:?}",
9320                self.peek()
9321            )));
9322        }
9323        self.advance(); // WHERE
9324        let cond = self.parse_expr(0)?;
9325        if !matches!(self.peek(), Token::RParen) {
9326            return Err(self.err(format!(
9327                "expected ')' to close FILTER (WHERE ...), got {:?}",
9328                self.peek()
9329            )));
9330        }
9331        self.advance(); // )
9332        Ok(Some(Box::new(cond)))
9333    }
9334
9335    /// v7.32 (round-29) — `WITHIN GROUP ( ORDER BY <sort_spec> )` tail
9336    /// for ordered-set aggregates. `WITHIN` is unreserved (arrives as an
9337    /// `Ident`); `GROUP` and `ORDER`/`BY` are keywords. Returns the sort
9338    /// keys, or an empty vec when no `WITHIN GROUP` follows.
9339    fn parse_within_group_clause(&mut self) -> Result<Vec<OrderBy>, ParseError> {
9340        let (Token::Ident(s) | Token::QuotedIdent(s)) = self.peek() else {
9341            return Ok(Vec::new());
9342        };
9343        if !s.eq_ignore_ascii_case("within") {
9344            return Ok(Vec::new());
9345        }
9346        self.advance(); // WITHIN
9347        if !matches!(self.peek(), Token::Group) {
9348            return Err(self.err(format!(
9349                "expected GROUP after WITHIN, got {:?}",
9350                self.peek()
9351            )));
9352        }
9353        self.advance(); // GROUP
9354        if !matches!(self.peek(), Token::LParen) {
9355            return Err(self.err(format!(
9356                "expected '(' after WITHIN GROUP, got {:?}",
9357                self.peek()
9358            )));
9359        }
9360        self.advance(); // (
9361        if !matches!(self.peek(), Token::Order) {
9362            return Err(self.err(format!(
9363                "expected ORDER BY inside WITHIN GROUP (...), got {:?}",
9364                self.peek()
9365            )));
9366        }
9367        self.advance(); // ORDER
9368        if !matches!(self.peek(), Token::By) {
9369            return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9370        }
9371        self.advance(); // BY
9372        let mut keys: Vec<OrderBy> = Vec::new();
9373        loop {
9374            let expr = self.parse_expr(0)?;
9375            let desc = if matches!(self.peek(), Token::Desc) {
9376                self.advance();
9377                true
9378            } else if matches!(self.peek(), Token::Asc) {
9379                self.advance();
9380                false
9381            } else {
9382                false
9383            };
9384            let nulls_first = self.parse_optional_nulls_placement()?;
9385            keys.push(OrderBy {
9386                expr,
9387                desc,
9388                nulls_first,
9389            });
9390            if matches!(self.peek(), Token::Comma) {
9391                self.advance();
9392            } else {
9393                break;
9394            }
9395        }
9396        if !matches!(self.peek(), Token::RParen) {
9397            return Err(self.err(format!(
9398                "expected ')' to close WITHIN GROUP (ORDER BY ...), got {:?}",
9399                self.peek()
9400            )));
9401        }
9402        self.advance(); // )
9403        Ok(keys)
9404    }
9405
9406    /// No frame clause is supported.
9407    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
9408    fn parse_over_clause(
9409        &mut self,
9410    ) -> Result<
9411        (
9412            Vec<Expr>,
9413            Vec<(Expr, bool, Option<bool>)>,
9414            Option<WindowFrame>,
9415        ),
9416        ParseError,
9417    > {
9418        if !matches!(self.peek(), Token::LParen) {
9419            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
9420        }
9421        self.advance();
9422        let mut partition_by = Vec::new();
9423        let mut order_by = Vec::new();
9424        // PARTITION BY ?
9425        // v7.37.6-B promoted PARTITION to a reserved keyword
9426        // (Token::Partition); pre-7.37.6-B catalogs lexed it as
9427        // `Token::Ident("partition")`. Accept both so older sources
9428        // and the new lexer surface land on the same path.
9429        let is_partition_kw = match self.peek() {
9430            Token::Partition => true,
9431            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("partition"),
9432            _ => false,
9433        };
9434        if is_partition_kw {
9435            self.advance();
9436            if !matches!(self.peek(), Token::By) {
9437                return Err(self.err(format!(
9438                    "expected BY after PARTITION, got {:?}",
9439                    self.peek()
9440                )));
9441            }
9442            self.advance();
9443            loop {
9444                partition_by.push(self.parse_expr(0)?);
9445                if matches!(self.peek(), Token::Comma) {
9446                    self.advance();
9447                    continue;
9448                }
9449                break;
9450            }
9451        }
9452        // ORDER BY ?
9453        if matches!(self.peek(), Token::Order) {
9454            self.advance();
9455            if !matches!(self.peek(), Token::By) {
9456                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
9457            }
9458            self.advance();
9459            loop {
9460                let e = self.parse_expr(0)?;
9461                let desc = if matches!(self.peek(), Token::Desc) {
9462                    self.advance();
9463                    true
9464                } else if matches!(self.peek(), Token::Asc) {
9465                    self.advance();
9466                    false
9467                } else {
9468                    false
9469                };
9470                // v7.24.1 — NULLS FIRST/LAST inside OVER (…).
9471                let nulls_first = self.parse_optional_nulls_placement()?;
9472                order_by.push((e, desc, nulls_first));
9473                if matches!(self.peek(), Token::Comma) {
9474                    self.advance();
9475                    continue;
9476                }
9477                break;
9478            }
9479        }
9480        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
9481        // Both keywords come through the lexer as identifiers; match
9482        // case-insensitively.
9483        let mut frame: Option<WindowFrame> = None;
9484        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
9485            let kind = if s.eq_ignore_ascii_case("rows") {
9486                Some(FrameKind::Rows)
9487            } else if s.eq_ignore_ascii_case("range") {
9488                Some(FrameKind::Range)
9489            } else {
9490                None
9491            };
9492            if let Some(kind) = kind {
9493                self.advance();
9494                frame = Some(self.parse_frame_tail(kind)?);
9495            }
9496        }
9497        if !matches!(self.peek(), Token::RParen) {
9498            return Err(self.err(format!(
9499                "expected ')' to close OVER clause, got {:?}",
9500                self.peek()
9501            )));
9502        }
9503        self.advance();
9504        Ok((partition_by, order_by, frame))
9505    }
9506
9507    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
9508    /// or `RANGE` keyword was just consumed. Accepts both
9509    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
9510    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
9511    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
9512    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
9513        if matches!(self.peek(), Token::Between) {
9514            self.advance();
9515            let start = self.parse_frame_bound()?;
9516            if !matches!(self.peek(), Token::And) {
9517                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
9518            }
9519            self.advance();
9520            let end = self.parse_frame_bound()?;
9521            Ok(WindowFrame {
9522                kind,
9523                start,
9524                end: Some(end),
9525            })
9526        } else {
9527            let start = self.parse_frame_bound()?;
9528            Ok(WindowFrame {
9529                kind,
9530                start,
9531                end: None,
9532            })
9533        }
9534    }
9535
9536    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
9537    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
9538    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
9539        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
9540        if let Token::Integer(n) = *self.peek() {
9541            self.advance();
9542            let n: u64 = u64::try_from(n).map_err(|_| {
9543                self.err(format!(
9544                    "invalid frame offset {n} — expected non-negative integer"
9545                ))
9546            })?;
9547            let dir = self.expect_ident_like()?;
9548            return if dir.eq_ignore_ascii_case("preceding") {
9549                Ok(FrameBound::OffsetPreceding(n))
9550            } else if dir.eq_ignore_ascii_case("following") {
9551                Ok(FrameBound::OffsetFollowing(n))
9552            } else {
9553                Err(self.err(format!(
9554                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
9555                )))
9556            };
9557        }
9558        let first = self.expect_ident_like()?;
9559        if first.eq_ignore_ascii_case("unbounded") {
9560            let dir = self.expect_ident_like()?;
9561            return if dir.eq_ignore_ascii_case("preceding") {
9562                Ok(FrameBound::UnboundedPreceding)
9563            } else if dir.eq_ignore_ascii_case("following") {
9564                Ok(FrameBound::UnboundedFollowing)
9565            } else {
9566                Err(self.err(format!(
9567                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
9568                )))
9569            };
9570        }
9571        if first.eq_ignore_ascii_case("current") {
9572            let row = self.expect_ident_like()?;
9573            if !row.eq_ignore_ascii_case("row") {
9574                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
9575            }
9576            return Ok(FrameBound::CurrentRow);
9577        }
9578        Err(self.err(format!(
9579            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
9580        )))
9581    }
9582
9583    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
9584        if matches!(self.peek(), Token::Dot) {
9585            self.advance();
9586            let name = self.expect_ident_like()?;
9587            // v7.14.0 — schema-qualified function call
9588            // `<schema>.<fn>(args)`. PG dumps emit
9589            // `pg_catalog.set_config(...)` in the preamble. SPG
9590            // is single-namespace: drop the schema prefix and
9591            // route the dispatch on the bare function name.
9592            if matches!(self.peek(), Token::LParen) {
9593                return self.finish_ident_atom(name);
9594            }
9595            return Ok(Expr::Column(ColumnName {
9596                qualifier: Some(first),
9597                name,
9598            }));
9599        }
9600        if matches!(self.peek(), Token::LParen) {
9601            self.advance();
9602            // `COUNT(*)` — special-cased here because `*` isn't a normal
9603            // expression token. Lower-case match on `first` since the lexer
9604            // folds identifiers.
9605            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
9606                self.advance();
9607                if !matches!(self.peek(), Token::RParen) {
9608                    return Err(self.err(format!(
9609                        "expected ')' after COUNT(*), got {:?}",
9610                        self.peek()
9611                    )));
9612                }
9613                self.advance();
9614                // v7.32 (round-29) — `COUNT(*) FILTER (WHERE …)`.
9615                let filter = self.parse_filter_clause()?;
9616                // v4.12: COUNT(*) OVER (...) — same window tail.
9617                let null_treatment = self.parse_null_treatment_modifier();
9618                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9619                    && s.eq_ignore_ascii_case("over")
9620                {
9621                    if filter.is_some() {
9622                        return Err(
9623                            self.err("FILTER on window functions is not supported yet".into())
9624                        );
9625                    }
9626                    self.advance();
9627                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
9628                    return Ok(Expr::WindowFunction {
9629                        name: "count_star".into(),
9630                        args: Vec::new(),
9631                        partition_by,
9632                        order_by,
9633                        frame,
9634                        null_treatment,
9635                    });
9636                }
9637                if let Some(filter) = filter {
9638                    return Ok(Expr::AggregateOrdered {
9639                        call: Box::new(Expr::FunctionCall {
9640                            name: "count_star".into(),
9641                            args: Vec::new(),
9642                        }),
9643                        order_by: Vec::new(),
9644                        distinct: false,
9645                        filter: Some(filter),
9646                    });
9647                }
9648                return Ok(Expr::FunctionCall {
9649                    name: "count_star".into(),
9650                    args: Vec::new(),
9651                });
9652            }
9653            // Function call. PG-style: zero-or-more comma-separated args.
9654            let mut args = Vec::new();
9655            let mut agg_order_by: Vec<OrderBy> = Vec::new();
9656            // v7.25 (round-17) — `COUNT(DISTINCT x)` and friends.
9657            // v7.32 (round-29) — accept the dual `ALL` quantifier too
9658            // (the default; ORMs emit `COUNT(ALL x)` / `SUM(ALL x)`).
9659            let agg_distinct = if matches!(self.peek(), Token::Distinct) {
9660                self.advance();
9661                true
9662            } else if matches!(self.peek(), Token::All) {
9663                self.advance();
9664                false
9665            } else {
9666                false
9667            };
9668            if !matches!(self.peek(), Token::RParen) {
9669                loop {
9670                    args.push(self.parse_expr(0)?);
9671                    // v7.25 (round-17) — standard `CAST(expr AS type)`.
9672                    // The `::` cast already worked; this lowers the
9673                    // function form onto the same Expr::Cast node.
9674                    if first.eq_ignore_ascii_case("cast")
9675                        && args.len() == 1
9676                        && matches!(self.peek(), Token::As)
9677                    {
9678                        self.advance();
9679                        let target = self.parse_cast_target()?;
9680                        if !matches!(self.peek(), Token::RParen) {
9681                            return Err(self.err(format!(
9682                                "expected ')' to close CAST, got {:?}",
9683                                self.peek()
9684                            )));
9685                        }
9686                        self.advance();
9687                        return Ok(Expr::Cast {
9688                            expr: Box::new(args.pop().expect("one arg")),
9689                            target,
9690                        });
9691                    }
9692                    // v7.24 (round-16 A) — aggregate-internal
9693                    // ordering: `array_agg(x ORDER BY y DESC NULLS
9694                    // LAST)`. Keys close the argument list.
9695                    if matches!(self.peek(), Token::Order) {
9696                        self.advance();
9697                        if !matches!(self.peek(), Token::By) {
9698                            return Err(self.err(format!(
9699                                "expected BY after ORDER in aggregate args, got {:?}",
9700                                self.peek()
9701                            )));
9702                        }
9703                        self.advance();
9704                        loop {
9705                            let expr = self.parse_expr(0)?;
9706                            let desc = if matches!(self.peek(), Token::Desc) {
9707                                self.advance();
9708                                true
9709                            } else if matches!(self.peek(), Token::Asc) {
9710                                self.advance();
9711                                false
9712                            } else {
9713                                false
9714                            };
9715                            let nulls_first = self.parse_optional_nulls_placement()?;
9716                            agg_order_by.push(OrderBy {
9717                                expr,
9718                                desc,
9719                                nulls_first,
9720                            });
9721                            if matches!(self.peek(), Token::Comma) {
9722                                self.advance();
9723                            } else {
9724                                break;
9725                            }
9726                        }
9727                        if !matches!(self.peek(), Token::RParen) {
9728                            return Err(self.err(format!(
9729                                "expected ')' after aggregate ORDER BY, got {:?}",
9730                                self.peek()
9731                            )));
9732                        }
9733                        break;
9734                    }
9735                    match self.peek() {
9736                        Token::Comma => {
9737                            self.advance();
9738                        }
9739                        Token::RParen => break,
9740                        other => {
9741                            return Err(self.err(format!(
9742                                "expected ',' or ')' in function args, got {other:?}"
9743                            )));
9744                        }
9745                    }
9746                }
9747            }
9748            self.advance(); // consume ')'
9749            // v7.32 (round-29) — ordered-set aggregate tail
9750            // `name(direct_args) WITHIN GROUP (ORDER BY …)`
9751            // (percentile_cont / percentile_disc / mode). The sort spec
9752            // lands in the same `order_by` slot a decorated aggregate
9753            // uses; the executor dispatches on the function name. WITHIN
9754            // GROUP and an intra-argument ORDER BY are mutually
9755            // exclusive (PG rejects both).
9756            let within_group_order = self.parse_within_group_clause()?;
9757            if !within_group_order.is_empty() && !agg_order_by.is_empty() {
9758                return Err(self.err(
9759                    "an aggregate may not carry both an in-argument ORDER BY and WITHIN GROUP"
9760                        .into(),
9761                ));
9762            }
9763            let agg_order_by = if within_group_order.is_empty() {
9764                agg_order_by
9765            } else {
9766                within_group_order
9767            };
9768            // v7.32 (round-29) — `name(args) FILTER (WHERE …)`.
9769            let filter = self.parse_filter_clause()?;
9770            // v4.12: window-function tail — `name(args) OVER (...)`.
9771            // Promotes the just-parsed FunctionCall into a
9772            // WindowFunction node carrying partition + order.
9773            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
9774            // / `RESPECT NULLS OVER (...)` between the closing paren
9775            // and `OVER`.
9776            let null_treatment = self.parse_null_treatment_modifier();
9777            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
9778                && s.eq_ignore_ascii_case("over")
9779            {
9780                if filter.is_some() {
9781                    return Err(self.err("FILTER on window functions is not supported yet".into()));
9782                }
9783                self.advance();
9784                let (partition_by, order_by, frame) = self.parse_over_clause()?;
9785                return Ok(Expr::WindowFunction {
9786                    name: first,
9787                    args,
9788                    partition_by,
9789                    order_by,
9790                    frame,
9791                    null_treatment,
9792                });
9793            }
9794            if !agg_order_by.is_empty() || agg_distinct || filter.is_some() {
9795                return Ok(Expr::AggregateOrdered {
9796                    call: Box::new(Expr::FunctionCall { name: first, args }),
9797                    order_by: agg_order_by,
9798                    distinct: agg_distinct,
9799                    filter,
9800                });
9801            }
9802            return Ok(Expr::FunctionCall { name: first, args });
9803        }
9804        // v7.9.20 — SQL-standard parenless keyword expressions
9805        // (PG treats these as functions called without parens).
9806        // Resolve to a synthetic FunctionCall so the engine's
9807        // eval path reuses the existing function-call routing.
9808        // mailrs G3.
9809        let lc = first.to_ascii_lowercase();
9810        if matches!(
9811            lc.as_str(),
9812            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
9813        ) {
9814            return Ok(Expr::FunctionCall {
9815                name: lc,
9816                args: Vec::new(),
9817            });
9818        }
9819        Ok(Expr::Column(ColumnName {
9820            qualifier: None,
9821            name: first,
9822        }))
9823    }
9824}
9825
9826/// v6.8.2 — walk an expression tree and return the first column
9827/// reference's bare name. Used by `parse_create_index_stmt_after_create`
9828/// to derive `CreateIndexStatement.column` from an expression
9829/// key (so downstream planner code resolving a primary column
9830/// position keeps working with expression indexes). Returns
9831/// `None` when the expression has no column ref at all — caller
9832/// surfaces that as a parse error.
9833fn extract_first_column(expr: &Expr) -> Option<String> {
9834    match expr {
9835        Expr::Column(cn) => Some(cn.name.clone()),
9836        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
9837        Expr::Binary { lhs, rhs, .. } => {
9838            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
9839        }
9840        Expr::Unary { expr: e, .. } => extract_first_column(e),
9841        _ => None,
9842    }
9843}
9844
9845fn maybe_not(expr: Expr, negated: bool) -> Expr {
9846    if negated {
9847        Expr::Unary {
9848            op: UnOp::Not,
9849            expr: Box::new(expr),
9850        }
9851    } else {
9852        expr
9853    }
9854}
9855
9856fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
9857    let pair = match tok {
9858        Token::Or => (BinOp::Or, 1),
9859        Token::And => (BinOp::And, 2),
9860        Token::Eq => (BinOp::Eq, 4),
9861        Token::NotEq => (BinOp::NotEq, 4),
9862        Token::Lt => (BinOp::Lt, 4),
9863        Token::LtEq => (BinOp::LtEq, 4),
9864        Token::Gt => (BinOp::Gt, 4),
9865        Token::GtEq => (BinOp::GtEq, 4),
9866        // pgvector distance ops all sit on the same rung — tighter than
9867        // comparisons (4) so `col <-> v < threshold` parses correctly.
9868        Token::L2Distance => (BinOp::L2Distance, 5),
9869        Token::InnerProduct => (BinOp::InnerProduct, 5),
9870        Token::CosineDistance => (BinOp::CosineDistance, 5),
9871        Token::Plus => (BinOp::Add, 6),
9872        Token::Minus => (BinOp::Sub, 6),
9873        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
9874        // by the same level as binary additive arithmetic).
9875        Token::Concat => (BinOp::Concat, 6),
9876        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
9877        // all "other" operators between additive and comparison, so
9878        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
9879        //
9880        // Known divergence (the same one `||` has carried since v1):
9881        // SPG's rung 6 TIES with `+ -`, while PG binds generic
9882        // operators LOOSER than additive — `a & b + 1` is
9883        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
9884        // mixed bitwise/arithmetic. Keeping every generic operator
9885        // on one shared rung is deliberate: splitting bitwise off
9886        // would fix that case but skew `a || b & c`, which PG
9887        // left-folds at a single level.
9888        Token::Pipe => (BinOp::BitOr, 6),
9889        Token::Amp => (BinOp::BitAnd, 6),
9890        Token::Star => (BinOp::Mul, 7),
9891        Token::Slash => (BinOp::Div, 7),
9892        // v4.14: JSON path ops bind tighter than comparisons (4)
9893        // and additive (6) so `doc->'k' = 'v'` parses correctly.
9894        // Same rung as the multiplicative ops.
9895        Token::JsonGet => (BinOp::JsonGet, 7),
9896        Token::JsonGetText => (BinOp::JsonGetText, 7),
9897        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
9898        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
9899        Token::JsonContains => (BinOp::JsonContains, 7),
9900        Token::JsonContainedBy => (BinOp::JsonContainedBy, 7),
9901        Token::JsonKeyExists => (BinOp::JsonKeyExists, 7),
9902        Token::JsonKeysAny => (BinOp::JsonKeysAny, 7),
9903        Token::JsonKeysAll => (BinOp::JsonKeysAll, 7),
9904        // v7.12.2 — `@@` binds at the comparison rung (looser than
9905        // arithmetic, tighter than AND / OR). PG places `@@` at
9906        // the same precedence as `=` / `<`, so we follow.
9907        Token::TsMatch => (BinOp::TsMatch, 4),
9908        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
9909        // PG places these at the comparison rung (same level as `=`),
9910        // so we follow.
9911        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
9912        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
9913        Token::InetContains => (BinOp::InetContains, 4),
9914        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
9915        Token::InetOverlap => (BinOp::InetOverlap, 4),
9916        _ => return None,
9917    };
9918    Some(pair)
9919}
9920
9921#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
9922// `as f32` here is intentional: vector elements widen / narrow into f32 on
9923// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
9924// past ~15 decimal digits — both are acceptable for a fixed-precision
9925// pgvector column.
9926/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
9927/// implicit table alias and break trailing clauses. WITH lands
9928/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
9929/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
9930/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
9931/// / VALUES / FOR / LATERAL — all of which would otherwise be
9932/// silently swallowed by `parse_optional_alias`.
9933fn is_alias_stopword(s: &str) -> bool {
9934    matches!(
9935        s.to_ascii_lowercase().as_str(),
9936        "with"
9937            | "on"
9938            | "where"
9939            | "having"
9940            | "group"
9941            | "order"
9942            | "limit"
9943            | "offset"
9944            | "union"
9945            | "except"
9946            | "intersect"
9947            | "returning"
9948            | "set"
9949            | "values"
9950            | "for"
9951            | "lateral"
9952            | "left"
9953            | "right"
9954            | "inner"
9955            | "outer"
9956            | "full"
9957            | "cross"
9958            | "join"
9959            | "natural"
9960            | "using"
9961            | "fetch"
9962    )
9963}
9964
9965fn extract_numeric_literal(e: &Expr) -> Option<f32> {
9966    match e {
9967        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
9968        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
9969        Expr::Unary {
9970            op: UnOp::Neg,
9971            expr,
9972        } => extract_numeric_literal(expr).map(|x| -x),
9973        _ => None,
9974    }
9975}
9976
9977/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
9978/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
9979/// negative. Returns `None` if any pair fails to parse or no pair is found.
9980///
9981/// Recognised units (case-insensitive, optional trailing `s`):
9982/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
9983/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
9984/// v7.37.5 β — returns `(months, days, micros)`. `days` is preserved
9985/// as its own dimension so `INTERVAL '1 day'` ≠ `INTERVAL '24 hours'`
9986/// (PG-canonical: DST and month-boundary semantics depend on this).
9987/// `week` rolls into `days` (× 7). Sub-day units flow into `micros`.
9988pub fn parse_interval_text(s: &str) -> Option<(i32, i32, i64)> {
9989    let parts: Vec<&str> = s.split_whitespace().collect();
9990    if parts.is_empty() || !parts.len().is_multiple_of(2) {
9991        return None;
9992    }
9993    let mut months: i32 = 0;
9994    let mut days: i32 = 0;
9995    let mut micros: i64 = 0;
9996    let mut i = 0;
9997    while i < parts.len() {
9998        let n: i64 = parts[i].parse().ok()?;
9999        let unit = parts[i + 1].to_ascii_lowercase();
10000        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
10001        match unit_stripped {
10002            "microsecond" => micros = micros.checked_add(n)?,
10003            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
10004            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
10005            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
10006            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
10007            "day" => {
10008                let n32 = i32::try_from(n).ok()?;
10009                days = days.checked_add(n32)?;
10010            }
10011            "week" => {
10012                let n32 = i32::try_from(n).ok()?;
10013                days = days.checked_add(n32.checked_mul(7)?)?;
10014            }
10015            // v7.37.5 ship triage — accept PG's `format_interval`
10016            // canonical output (`0 mons 0 days 0 microseconds`) so
10017            // a round-trip Display → re-parse stays lossless.
10018            "month" | "mon" => {
10019                let n32 = i32::try_from(n).ok()?;
10020                months = months.checked_add(n32)?;
10021            }
10022            "year" => {
10023                let n32 = i32::try_from(n).ok()?;
10024                months = months.checked_add(n32.checked_mul(12)?)?;
10025            }
10026            _ => return None,
10027        }
10028        i += 2;
10029    }
10030    Some((months, days, micros))
10031}
10032
10033/// v7.12.4 — map a bare type-name identifier (the form that
10034/// appears in a function arg list or RETURNS clause) to a
10035/// [`ColumnTypeName`]. Returns `None` for unknown / extension
10036/// types so the caller can preserve them as
10037/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
10038///
10039/// Subset of the full column-type grammar — we deliberately
10040/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
10041/// here because function-arg types in v7.12.4 are mostly the
10042/// bare form (`text`, `int`, `bytea`, …).
10043fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
10044    Some(match ident.to_ascii_lowercase().as_str() {
10045        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
10046        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
10047        "bigint" => ColumnTypeName::BigInt,
10048        "float" | "double" | "real" => ColumnTypeName::Float,
10049        "text" => ColumnTypeName::Text,
10050        "bool" | "boolean" => ColumnTypeName::Bool,
10051        "date" => ColumnTypeName::Date,
10052        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
10053        "timestamptz" => ColumnTypeName::Timestamptz,
10054        "json" => ColumnTypeName::Json,
10055        "jsonb" => ColumnTypeName::Jsonb,
10056        "bytea" | "bytes" => ColumnTypeName::Bytes,
10057        "tsvector" => ColumnTypeName::TsVector,
10058        "tsquery" => ColumnTypeName::TsQuery,
10059        "uuid" => ColumnTypeName::Uuid,
10060        "interval" => ColumnTypeName::Interval,
10061        "time" => ColumnTypeName::Time,
10062        "year" => ColumnTypeName::Year,
10063        "timetz" => ColumnTypeName::TimeTz,
10064        "money" => ColumnTypeName::Money,
10065        _ => return None,
10066    })
10067}
10068
10069/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
10070/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
10071///
10072/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
10073/// / embedded SQL land in v7.12.5+):
10074///
10075/// ```text
10076///   body          := [ws] block [ws]
10077///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
10078///   stmt          := assign | return
10079///   assign        := assign_target := expr
10080///   assign_target := ( NEW | OLD ) . ident | ident
10081///   return        := RETURN ( NEW | OLD | NULL | expr )
10082/// ```
10083///
10084/// `expr` is parsed by recursing into the regular `Parser` — so a
10085/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
10086/// NEW.subject || ' ' || NEW.sender)` body shape works without
10087/// the body parser knowing what `to_tsvector` is.
10088///
10089/// Errors here cause the caller to fall back to
10090/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
10091/// successful, but the executor will refuse to invoke the
10092/// function with an "unparseable body" error.
10093/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
10094/// from the crate root as `spg_sql::parse_function_body`.
10095pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10096    parse_plpgsql_body(body)
10097}
10098
10099fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
10100    // Use the regular lexer on the body text. The trailing
10101    // `END;` may or may not have a semicolon; the lexer treats
10102    // both forms identically.
10103    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
10104        message: alloc::format!("plpgsql body lex error: {e}"),
10105        token_pos: 0,
10106    })?;
10107    let mut parser = Parser::new(tokens);
10108    parser.parse_plpgsql_block()
10109}
10110
10111#[cfg(test)]
10112mod tests {
10113    use super::*;
10114    use alloc::string::ToString;
10115
10116    fn parse(s: &str) -> Statement {
10117        parse_statement(s).expect("parse ok")
10118    }
10119
10120    // v7.37.43-T4 sentori cutover acceptance — `release`, `index`,
10121    // `tables`, `partition`, etc. are unreserved keywords per PG's
10122    // `pg_get_keywords()` and MUST be usable as column / table /
10123    // alias names. Pre-T4 every drop-in user whose schema had one
10124    // of these as a column name (sentori events.release, mailrs
10125    // messages.index in some forks) blew the parser up at CREATE
10126    // TABLE time with "expected identifier, got Release". The
10127    // generalisation lives in `unreserved_keyword_text` + the
10128    // `expect_ident_like` and `parse_atom` arms that consult it.
10129    #[test]
10130    fn release_usable_as_column_name_in_create_table() {
10131        let stmt =
10132            parse("CREATE TABLE events (id INT PRIMARY KEY, release TEXT NOT NULL, payload TEXT)");
10133        if let Statement::CreateTable(t) = stmt {
10134            let names: alloc::vec::Vec<&str> = t.columns.iter().map(|c| c.name.as_str()).collect();
10135            assert_eq!(names, alloc::vec!["id", "release", "payload"]);
10136        } else {
10137            panic!("expected CreateTable");
10138        }
10139    }
10140
10141    #[test]
10142    fn release_usable_as_column_ref_in_select_projection() {
10143        // The sentori `0003_partition_events.sql` INSERT-SELECT
10144        // walk references `release` in both column lists; the
10145        // projection-side use exercises `parse_atom`'s relaxed
10146        // identifier set.
10147        parse("SELECT id, release, payload FROM events WHERE id = 1");
10148    }
10149
10150    #[test]
10151    fn release_usable_as_column_ref_in_insert_column_list() {
10152        // INSERT INTO t (id, release, payload) VALUES (…)
10153        parse("INSERT INTO events (id, release, payload) VALUES (1, '1.0.0', 'data')");
10154    }
10155
10156    #[test]
10157    fn alter_column_drop_not_null_uses_keyword_drop_token() {
10158        // Sentori `0013_audit_tombstone.sql` issues
10159        // `ALTER TABLE … ALTER COLUMN x DROP NOT NULL`. The lexer
10160        // emits Token::Drop (not Ident("drop")); the parser must
10161        // accept both in the ALTER COLUMN sub-dispatch.
10162        parse("ALTER TABLE audit_logs ALTER COLUMN org_id DROP NOT NULL");
10163    }
10164
10165    #[test]
10166    fn create_index_accepts_parenthesised_expression_key() {
10167        // sentori `0040_events_bundle_idx.sql` shape — JSONB
10168        // expression index. Pre-T4 the parser bailed at the
10169        // inner `(` with "expected column ident or expression,
10170        // got LParen". The Token::LParen arm in CREATE INDEX
10171        // routes through the expression parser instead.
10172        parse(
10173            "CREATE INDEX IF NOT EXISTS events_bundle_id_idx \
10174             ON events ((payload->'bundle'->>'id'))",
10175        );
10176    }
10177
10178    // v7.30.2 (mailrs round-25 ask 2) — nesting / chain budgets must
10179    // surface as parse errors, never stack overflows (embed hosts
10180    // abort on overflow).
10181    #[test]
10182    fn nesting_budget_errors_cleanly() {
10183        let depth = MAX_NEST_DEPTH + 50;
10184        let sql = format!("SELECT {}1{}", "(".repeat(depth), ")".repeat(depth));
10185        let err = parse_statement(&sql).expect_err("must reject");
10186        assert!(err.message.contains("nests deeper"), "{err:?}");
10187        // Within budget still parses.
10188        let sql = format!("SELECT {}1{}", "(".repeat(48), ")".repeat(48));
10189        parse(&sql);
10190    }
10191
10192    #[test]
10193    fn binary_chain_budget_errors_cleanly() {
10194        let sql = format!("SELECT 1{}", " + 1".repeat(MAX_BINARY_CHAIN + 50));
10195        let err = parse_statement(&sql).expect_err("must reject");
10196        assert!(err.message.contains("chained binary"), "{err:?}");
10197        // Within budget still parses (chain depth ≤ budget is safe
10198        // for recursive eval/drop on 2 MiB stacks).
10199        let sql = format!("SELECT 1{}", " + 1".repeat(200));
10200        parse(&sql);
10201    }
10202
10203    #[test]
10204    fn in_list_unaffected_by_chain_budget() {
10205        // Flat InList: 20k elements parse fine and stay flat.
10206        let items: alloc::vec::Vec<String> = (0..20_000).map(|k| k.to_string()).collect();
10207        let sql = format!("SELECT 1 WHERE 5 IN ({})", items.join(","));
10208        let Statement::Select(s) = parse(&sql) else {
10209            panic!("expected select")
10210        };
10211        let Some(Expr::InList { list, negated, .. }) = s.where_ else {
10212            panic!("expected flat InList, got {:?}", s.where_)
10213        };
10214        assert_eq!(list.len(), 20_000);
10215        assert!(!negated);
10216    }
10217
10218    fn lit_int(n: i64) -> Expr {
10219        Expr::Literal(Literal::Integer(n))
10220    }
10221
10222    fn col(name: &str) -> Expr {
10223        Expr::Column(ColumnName {
10224            qualifier: None,
10225            name: name.into(),
10226        })
10227    }
10228
10229    #[test]
10230    fn select_single_integer() {
10231        let s = parse("SELECT 1");
10232        let Statement::Select(s) = s else {
10233            panic!("expected SELECT")
10234        };
10235        assert_eq!(s.items.len(), 1);
10236        assert!(s.from.is_none());
10237        assert!(s.where_.is_none());
10238    }
10239
10240    #[test]
10241    fn select_multiple_literal_kinds() {
10242        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
10243        let Statement::Select(s) = s else {
10244            panic!("expected SELECT")
10245        };
10246        assert_eq!(s.items.len(), 5);
10247    }
10248
10249    #[test]
10250    fn select_wildcard_from_table() {
10251        let s = parse("SELECT * FROM users");
10252        let Statement::Select(s) = s else {
10253            panic!("expected SELECT")
10254        };
10255        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
10256        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
10257    }
10258
10259    #[test]
10260    fn select_with_table_alias() {
10261        let s = parse("SELECT * FROM users AS u");
10262        let Statement::Select(s) = s else {
10263            panic!("expected SELECT")
10264        };
10265        let t = &s.from.as_ref().unwrap().primary;
10266        assert_eq!(t.name, "users");
10267        assert_eq!(t.alias.as_deref(), Some("u"));
10268    }
10269
10270    #[test]
10271    fn select_with_where_eq() {
10272        let s = parse("SELECT a FROM t WHERE a = 1");
10273        let Statement::Select(s) = s else {
10274            panic!("expected SELECT")
10275        };
10276        let w = s.where_.unwrap();
10277        assert_eq!(
10278            w,
10279            Expr::Binary {
10280                lhs: Box::new(col("a")),
10281                op: BinOp::Eq,
10282                rhs: Box::new(lit_int(1)),
10283            }
10284        );
10285    }
10286
10287    #[test]
10288    fn arithmetic_precedence() {
10289        let s = parse("SELECT 1 + 2 * 3");
10290        let Statement::Select(s) = s else {
10291            panic!("expected SELECT")
10292        };
10293        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10294            panic!("wildcard?")
10295        };
10296        assert_eq!(
10297            expr,
10298            &Expr::Binary {
10299                lhs: Box::new(lit_int(1)),
10300                op: BinOp::Add,
10301                rhs: Box::new(Expr::Binary {
10302                    lhs: Box::new(lit_int(2)),
10303                    op: BinOp::Mul,
10304                    rhs: Box::new(lit_int(3)),
10305                }),
10306            }
10307        );
10308    }
10309
10310    #[test]
10311    fn parentheses_override_precedence() {
10312        let s = parse("SELECT (1 + 2) * 3");
10313        let Statement::Select(s) = s else {
10314            panic!("expected SELECT")
10315        };
10316        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10317            panic!()
10318        };
10319        assert_eq!(
10320            expr,
10321            &Expr::Binary {
10322                lhs: Box::new(Expr::Binary {
10323                    lhs: Box::new(lit_int(1)),
10324                    op: BinOp::Add,
10325                    rhs: Box::new(lit_int(2)),
10326                }),
10327                op: BinOp::Mul,
10328                rhs: Box::new(lit_int(3)),
10329            }
10330        );
10331    }
10332
10333    #[test]
10334    fn not_binds_below_comparison() {
10335        // `NOT a = 1` should parse as `NOT (a = 1)`.
10336        let s = parse("SELECT NOT a = 1 FROM t");
10337        let Statement::Select(s) = s else {
10338            panic!("expected SELECT")
10339        };
10340        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10341            panic!()
10342        };
10343        assert_eq!(
10344            expr,
10345            &Expr::Unary {
10346                op: UnOp::Not,
10347                expr: Box::new(Expr::Binary {
10348                    lhs: Box::new(col("a")),
10349                    op: BinOp::Eq,
10350                    rhs: Box::new(lit_int(1)),
10351                }),
10352            }
10353        );
10354    }
10355
10356    #[test]
10357    fn unary_minus_binds_above_multiplication() {
10358        // `-a * 2` should be `(-a) * 2`.
10359        let s = parse("SELECT -a * 2 FROM t");
10360        let Statement::Select(s) = s else {
10361            panic!("expected SELECT")
10362        };
10363        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10364            panic!()
10365        };
10366        assert_eq!(
10367            expr,
10368            &Expr::Binary {
10369                lhs: Box::new(Expr::Unary {
10370                    op: UnOp::Neg,
10371                    expr: Box::new(col("a")),
10372                }),
10373                op: BinOp::Mul,
10374                rhs: Box::new(lit_int(2)),
10375            }
10376        );
10377    }
10378
10379    #[test]
10380    fn qualified_column() {
10381        let s = parse("SELECT t.col FROM t");
10382        let Statement::Select(s) = s else {
10383            panic!("expected SELECT")
10384        };
10385        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10386            panic!()
10387        };
10388        assert_eq!(
10389            expr,
10390            &Expr::Column(ColumnName {
10391                qualifier: Some("t".into()),
10392                name: "col".into()
10393            })
10394        );
10395    }
10396
10397    #[test]
10398    fn select_item_alias_with_as() {
10399        let s = parse("SELECT a AS y FROM t");
10400        let Statement::Select(s) = s else {
10401            panic!("expected SELECT")
10402        };
10403        let SelectItem::Expr { alias, .. } = &s.items[0] else {
10404            panic!()
10405        };
10406        assert_eq!(alias.as_deref(), Some("y"));
10407    }
10408
10409    #[test]
10410    fn trailing_semicolon_accepted() {
10411        let s = parse("SELECT 1;");
10412        let Statement::Select(s) = s else {
10413            panic!("expected SELECT")
10414        };
10415        assert_eq!(s.items.len(), 1);
10416    }
10417
10418    #[test]
10419    fn boolean_chain_with_and_or_not() {
10420        // (NOT a) OR (b AND (NOT c))
10421        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
10422        let Statement::Select(s) = s else {
10423            panic!("expected SELECT")
10424        };
10425        let SelectItem::Expr { expr, .. } = &s.items[0] else {
10426            panic!()
10427        };
10428        let expected = Expr::Binary {
10429            lhs: Box::new(Expr::Unary {
10430                op: UnOp::Not,
10431                expr: Box::new(col("a")),
10432            }),
10433            op: BinOp::Or,
10434            rhs: Box::new(Expr::Binary {
10435                lhs: Box::new(col("b")),
10436                op: BinOp::And,
10437                rhs: Box::new(Expr::Unary {
10438                    op: UnOp::Not,
10439                    expr: Box::new(col("c")),
10440                }),
10441            }),
10442        };
10443        assert_eq!(expr, &expected);
10444    }
10445
10446    #[test]
10447    fn empty_input_errors() {
10448        // v7.14.0 — pg_dump preambles emit several comment-only
10449        // / blank-line statements that collapse to Statement::
10450        // Empty rather than a parse error. The old "SELECT in
10451        // message" assertion is stale; verify the new contract:
10452        // empty / whitespace / comment-only input parses to
10453        // Statement::Empty.
10454        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
10455        assert!(matches!(
10456            parse_statement("  \n\t ").unwrap(),
10457            Statement::Empty
10458        ));
10459        // Sanity: malformed-but-non-empty still errors.
10460        assert!(parse_statement("SELECT FROM WHERE").is_err());
10461    }
10462
10463    #[test]
10464    fn unmatched_paren_errors() {
10465        assert!(parse_statement("SELECT (1 + 2").is_err());
10466    }
10467
10468    #[test]
10469    fn display_round_trip_simple_select() {
10470        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
10471        let text = original.to_string();
10472        let again = parse_statement(&text).expect("re-parse");
10473        assert_eq!(original, again);
10474    }
10475
10476    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
10477
10478    #[test]
10479    fn create_table_single_column() {
10480        let s = parse("CREATE TABLE foo (a INT)");
10481        let Statement::CreateTable(c) = s else {
10482            panic!("expected CreateTable")
10483        };
10484        assert_eq!(c.name, "foo");
10485        assert_eq!(c.columns.len(), 1);
10486        assert_eq!(c.columns[0].name, "a");
10487        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10488        assert!(c.columns[0].nullable);
10489    }
10490
10491    #[test]
10492    fn create_table_multi_column_with_not_null_mix() {
10493        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
10494        let Statement::CreateTable(c) = s else {
10495            panic!()
10496        };
10497        assert_eq!(c.columns.len(), 4);
10498        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
10499        assert!(!c.columns[0].nullable);
10500        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
10501        assert!(c.columns[1].nullable);
10502        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
10503        assert!(!c.columns[2].nullable);
10504        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
10505    }
10506
10507    #[test]
10508    fn create_table_bigint_supported() {
10509        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
10510        let Statement::CreateTable(c) = s else {
10511            panic!()
10512        };
10513        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
10514    }
10515
10516    #[test]
10517    fn create_table_vector_default_is_f32() {
10518        let s = parse("CREATE TABLE t (v VECTOR(128))");
10519        let Statement::CreateTable(c) = s else {
10520            panic!()
10521        };
10522        assert_eq!(
10523            c.columns[0].ty,
10524            ColumnTypeName::Vector {
10525                dim: 128,
10526                encoding: VecEncoding::F32,
10527            },
10528        );
10529    }
10530
10531    #[test]
10532    fn create_table_vector_using_sq8() {
10533        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
10534        // Case-insensitive on both `USING` and the encoding name.
10535        for sql in [
10536            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
10537            "CREATE TABLE t (v VECTOR(128) using sq8)",
10538        ] {
10539            let s = parse(sql);
10540            let Statement::CreateTable(c) = s else {
10541                panic!()
10542            };
10543            assert_eq!(
10544                c.columns[0].ty,
10545                ColumnTypeName::Vector {
10546                    dim: 128,
10547                    encoding: VecEncoding::Sq8,
10548                },
10549                "{sql}",
10550            );
10551        }
10552    }
10553
10554    #[test]
10555    fn create_table_vector_using_unknown_errors() {
10556        // v7.16.1 — the inline `USING <encoding>` shape on
10557        // CREATE TABLE column defs was withdrawn before
10558        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
10559        // (col vector_<metric>_ops)`; the parser now rejects
10560        // USING at column-list position with a clearer
10561        // "expected ',' or ')'" message. Test asserts the
10562        // current rejection, not the old "unknown vector
10563        // encoding" string.
10564        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
10565        assert!(
10566            err.message.contains("USING")
10567                || err.message.contains("using")
10568                || err.message.contains("')'")
10569                || err.message.contains("','"),
10570            "expected USING/column-list rejection, got: {}",
10571            err.message
10572        );
10573    }
10574
10575    #[test]
10576    fn vector_using_sq8_display_roundtrips() {
10577        // The Display impl must produce text that re-parses to the
10578        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
10579        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
10580        let Statement::CreateTable(c) = s else {
10581            panic!()
10582        };
10583        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
10584    }
10585
10586    #[test]
10587    fn parser_recognises_placeholders() {
10588        use crate::ast::{Expr, SelectItem, Statement};
10589        // $N in expression position parses as Expr::Placeholder(N).
10590        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
10591        let Statement::Select(sel) = s else { panic!() };
10592        assert!(matches!(
10593            sel.items[0],
10594            SelectItem::Expr {
10595                expr: Expr::Placeholder(1),
10596                alias: None
10597            }
10598        ));
10599        // $2 + 1
10600        let SelectItem::Expr {
10601            expr: Expr::Binary { lhs, rhs, .. },
10602            ..
10603        } = &sel.items[1]
10604        else {
10605            panic!()
10606        };
10607        assert!(matches!(**lhs, Expr::Placeholder(2)));
10608        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
10609        // WHERE x = $3
10610        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
10611            panic!()
10612        };
10613        assert!(matches!(**rhs, Expr::Placeholder(3)));
10614    }
10615
10616    #[test]
10617    fn parser_rejects_dollar_zero() {
10618        // $0 is not valid in PG; the lexer rejects it.
10619        assert!(parse_statement("SELECT $0").is_err());
10620    }
10621
10622    #[test]
10623    fn placeholder_display_roundtrips() {
10624        // The Display impl must produce text that re-lexes to the
10625        // same Placeholder token.
10626        let s = parse("SELECT $42 FROM t");
10627        let printed = s.to_string();
10628        assert!(printed.contains("$42"));
10629        let again = parse(&printed);
10630        assert_eq!(s, again);
10631    }
10632
10633    #[test]
10634    fn alter_index_rebuild_bare() {
10635        use crate::ast::{AlterIndexTarget, Statement};
10636        let s = parse("ALTER INDEX my_idx REBUILD");
10637        let Statement::AlterIndex(a) = s else {
10638            panic!("expected AlterIndex, got {s:?}")
10639        };
10640        assert_eq!(a.name, "my_idx");
10641        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
10642    }
10643
10644    #[test]
10645    fn alter_index_rebuild_with_encoding() {
10646        use crate::ast::{AlterIndexTarget, Statement};
10647        for (sql, want) in [
10648            (
10649                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
10650                VecEncoding::F32,
10651            ),
10652            (
10653                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
10654                VecEncoding::Sq8,
10655            ),
10656            (
10657                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10658                VecEncoding::F16,
10659            ),
10660        ] {
10661            let s = parse(sql);
10662            let Statement::AlterIndex(a) = s else {
10663                panic!("{sql}: expected AlterIndex")
10664            };
10665            assert_eq!(a.name, "my_idx");
10666            assert_eq!(
10667                a.target,
10668                AlterIndexTarget::Rebuild {
10669                    encoding: Some(want)
10670                },
10671                "{sql}"
10672            );
10673        }
10674    }
10675
10676    #[test]
10677    fn alter_index_rebuild_unknown_encoding_errors() {
10678        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
10679        assert!(
10680            err.message.contains("unknown vector encoding"),
10681            "got: {}",
10682            err.message
10683        );
10684    }
10685
10686    #[test]
10687    fn alter_index_rebuild_display_roundtrips() {
10688        for (input, want) in [
10689            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
10690            (
10691                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
10692                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
10693            ),
10694            (
10695                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10696                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
10697            ),
10698        ] {
10699            let s = parse(input);
10700            assert_eq!(s.to_string(), want);
10701        }
10702    }
10703
10704    #[test]
10705    fn create_table_unknown_type_defers_to_engine() {
10706        // v4.9 picked XML as a parse-time "unsupported column
10707        // type" probe. v7.17.0 Phase 1.4 changed the contract:
10708        // an unknown type ident parses as Text + `user_type_ref`
10709        // so CREATE TABLE can resolve user-defined enum / domain
10710        // types — rejection of truly-unknown types moved to the
10711        // engine's catalog lookup. v7.37.5 ζ-A then promoted XML
10712        // to a first-class built-in, so this probe switched to a
10713        // synthetic name nothing in the lexer will ever recognise.
10714        let stmt = parse_statement("CREATE TABLE x (a my_user_type)").unwrap();
10715        let Statement::CreateTable(t) = stmt else {
10716            panic!("expected CreateTable");
10717        };
10718        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("my_user_type"));
10719    }
10720
10721    #[test]
10722    fn create_table_missing_table_keyword_errors() {
10723        assert!(parse_statement("CREATE x (a INT)").is_err());
10724    }
10725
10726    // v7.37.6-B(sentori Epic 2 P0)— `PARTITION BY RANGE` parent +
10727    // `PARTITION OF parent <bounds>` child parse + Display round-trip.
10728
10729    #[test]
10730    fn parse_create_table_partition_by_range() {
10731        use crate::ast::{PartitionBySpec, PartitionKindAst};
10732        let stmt = parse_statement(
10733            "CREATE TABLE events_partitioned (id BIGINT NOT NULL, ts TIMESTAMPTZ NOT NULL, \
10734             payload JSONB) PARTITION BY RANGE (ts)",
10735        )
10736        .unwrap();
10737        let Statement::CreateTable(t) = stmt else {
10738            panic!("expected CreateTable");
10739        };
10740        assert!(t.partition_of.is_none(), "parent has no partition_of");
10741        assert_eq!(t.columns.len(), 3);
10742        let by = t.partition_by.as_ref().expect("expected PARTITION BY");
10743        assert_eq!(
10744            by,
10745            &PartitionBySpec {
10746                kind: PartitionKindAst::Range,
10747                key_columns: alloc::vec!["ts".to_string()],
10748            }
10749        );
10750        // Display round-trip preserves the suffix. `quote_ident`
10751        // only adds double quotes when the ident needs escaping, so
10752        // a plain `ts` survives bare here.
10753        assert!(
10754            t.to_string().contains("PARTITION BY RANGE (ts)"),
10755            "Display lost PARTITION BY suffix: {t}"
10756        );
10757    }
10758
10759    #[test]
10760    fn parse_create_table_partition_of_range() {
10761        use crate::ast::{PartitionOfBoundsAst, PartitionOfSpec};
10762        let stmt = parse_statement(
10763            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned \
10764             FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00')",
10765        )
10766        .unwrap();
10767        let Statement::CreateTable(t) = stmt else {
10768            panic!("expected CreateTable");
10769        };
10770        assert!(t.columns.is_empty(), "child inherits columns from parent");
10771        assert!(t.partition_by.is_none());
10772        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
10773        assert_eq!(of.parent_name, "events_partitioned");
10774        let PartitionOfSpec { bounds, .. } = of.clone();
10775        match bounds {
10776            PartitionOfBoundsAst::Range { lower, upper } => {
10777                assert!(lower.to_string().contains("2026-06-01"));
10778                assert!(upper.to_string().contains("2026-07-01"));
10779            }
10780            PartitionOfBoundsAst::Default => panic!("expected Range, got Default"),
10781        }
10782        // Display round-trip emits the FOR VALUES tail. `quote_ident`
10783        // skips quotes when not required, so the parent name appears
10784        // bare here.
10785        let s = t.to_string();
10786        assert!(
10787            s.contains("PARTITION OF events_partitioned"),
10788            "Display lost PARTITION OF: {s}"
10789        );
10790        assert!(s.contains("FOR VALUES FROM"), "Display lost FROM: {s}");
10791        assert!(s.contains(") TO ("), "Display lost TO: {s}");
10792    }
10793
10794    #[test]
10795    fn parse_create_table_partition_of_default() {
10796        use crate::ast::PartitionOfBoundsAst;
10797        let stmt =
10798            parse_statement("CREATE TABLE events_default PARTITION OF events_partitioned DEFAULT")
10799                .unwrap();
10800        let Statement::CreateTable(t) = stmt else {
10801            panic!("expected CreateTable");
10802        };
10803        let of = t.partition_of.as_ref().expect("expected PARTITION OF");
10804        assert_eq!(of.parent_name, "events_partitioned");
10805        assert!(matches!(of.bounds, PartitionOfBoundsAst::Default));
10806        assert!(
10807            t.to_string()
10808                .contains("PARTITION OF events_partitioned DEFAULT"),
10809            "Display lost DEFAULT: {t}"
10810        );
10811    }
10812
10813    #[test]
10814    fn parse_create_table_partition_of_rejects_columns() {
10815        // v7.37.6-B contract: PARTITION OF children inherit columns
10816        // from the parent; an explicit list MUST surface as a parse
10817        // error rather than getting silently ignored.
10818        let err = parse_statement(
10819            "CREATE TABLE events_2026_06 PARTITION OF events_partitioned (id BIGINT) \
10820             FOR VALUES FROM ('a') TO ('b')",
10821        );
10822        assert!(err.is_err(), "expected parse error for explicit columns");
10823        let msg = format!("{}", err.unwrap_err());
10824        assert!(
10825            msg.contains("PARTITION OF") && msg.contains("column"),
10826            "error should mention PARTITION OF + columns: {msg}"
10827        );
10828    }
10829
10830    #[test]
10831    fn insert_single_value() {
10832        let s = parse("INSERT INTO foo VALUES (42)");
10833        let Statement::Insert(i) = s else {
10834            panic!("expected Insert")
10835        };
10836        assert_eq!(i.table, "foo");
10837        assert_eq!(i.rows.len(), 1);
10838        assert_eq!(i.rows[0].len(), 1);
10839        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
10840    }
10841
10842    #[test]
10843    fn insert_multi_value_with_mixed_literals() {
10844        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
10845        let Statement::Insert(i) = s else { panic!() };
10846        assert_eq!(i.rows.len(), 1);
10847        assert_eq!(i.rows[0].len(), 5);
10848    }
10849
10850    #[test]
10851    fn insert_missing_into_errors() {
10852        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
10853    }
10854
10855    #[test]
10856    fn create_table_round_trip() {
10857        let original =
10858            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
10859        let text = original.to_string();
10860        let again = parse_statement(&text).expect("re-parse");
10861        assert_eq!(original, again);
10862    }
10863
10864    #[test]
10865    fn insert_round_trip_with_negation_and_string() {
10866        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
10867        let text = original.to_string();
10868        let again = parse_statement(&text).expect("re-parse");
10869        assert_eq!(original, again);
10870    }
10871
10872    #[test]
10873    fn unknown_keyword_at_statement_start_errors() {
10874        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
10875        // the top-level dispatch still has no branch to take.
10876        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
10877        assert!(err.message.contains("expected SELECT"));
10878    }
10879
10880    // --- v0.8 CREATE INDEX --------------------------------------------------
10881
10882    #[test]
10883    fn create_index_basic() {
10884        let s = parse("CREATE INDEX idx_id ON users (id)");
10885        let Statement::CreateIndex(c) = s else {
10886            panic!("expected CreateIndex")
10887        };
10888        assert_eq!(c.name, "idx_id");
10889        assert_eq!(c.table, "users");
10890        assert_eq!(c.column, "id");
10891    }
10892
10893    #[test]
10894    fn create_index_missing_on_errors() {
10895        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
10896    }
10897
10898    #[test]
10899    fn create_index_missing_paren_errors() {
10900        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
10901    }
10902
10903    #[test]
10904    fn create_index_round_trip() {
10905        let original = parse("CREATE INDEX by_name ON users (name)");
10906        let again = parse_statement(&original.to_string()).unwrap();
10907        assert_eq!(original, again);
10908    }
10909
10910    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
10911
10912    #[test]
10913    fn create_unique_index_basic() {
10914        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
10915        let Statement::CreateIndex(c) = s else {
10916            panic!("expected CreateIndex");
10917        };
10918        assert!(c.is_unique);
10919        assert_eq!(c.column, "a");
10920        assert!(c.partial_predicate.is_none());
10921    }
10922
10923    #[test]
10924    fn create_unique_index_partial() {
10925        // mailrs's email_templates "one default per user" shape.
10926        let s = parse(
10927            "CREATE UNIQUE INDEX idx_email_templates_user_default \
10928             ON email_templates (user_address) WHERE is_default = true",
10929        );
10930        let Statement::CreateIndex(c) = s else {
10931            panic!("expected CreateIndex");
10932        };
10933        assert!(c.is_unique);
10934        assert_eq!(c.table, "email_templates");
10935        assert_eq!(c.column, "user_address");
10936        assert!(c.partial_predicate.is_some());
10937    }
10938
10939    #[test]
10940    fn create_unique_index_composite_with_predicate() {
10941        // mailrs's calendar_events instance: composite columns.
10942        let s = parse(
10943            "CREATE UNIQUE INDEX uq_calendar_events_instance \
10944             ON calendar_events (calendar_id, uid, recurrence_id) \
10945             WHERE recurrence_id IS NOT NULL",
10946        );
10947        let Statement::CreateIndex(c) = s else {
10948            panic!("expected CreateIndex");
10949        };
10950        assert!(c.is_unique);
10951        assert_eq!(c.column, "calendar_id");
10952        assert_eq!(
10953            c.extra_columns,
10954            vec!["uid".to_string(), "recurrence_id".to_string()]
10955        );
10956        assert!(c.partial_predicate.is_some());
10957    }
10958
10959    #[test]
10960    fn create_unique_index_using_btree_ok() {
10961        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
10962        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
10963    }
10964
10965    #[test]
10966    fn create_unique_index_using_hnsw_rejected() {
10967        let err =
10968            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
10969        assert!(err.message.contains("UNIQUE"), "{}", err.message);
10970    }
10971
10972    #[test]
10973    fn create_unique_index_round_trip() {
10974        let original = parse(
10975            "CREATE UNIQUE INDEX uq_calendar_events_master \
10976             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
10977        );
10978        let again = parse_statement(&original.to_string()).unwrap();
10979        assert_eq!(original, again);
10980    }
10981
10982    #[test]
10983    fn create_unique_without_index_errors() {
10984        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
10985        assert!(err.message.contains("INDEX"), "{}", err.message);
10986    }
10987
10988    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
10989
10990    #[test]
10991    fn create_table_bytea_column() {
10992        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
10993        let Statement::CreateTable(c) = s else {
10994            panic!("expected CreateTable");
10995        };
10996        assert_eq!(c.columns.len(), 2);
10997        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
10998        assert!(!c.columns[1].nullable);
10999    }
11000
11001    #[test]
11002    fn create_table_bytes_alias_column() {
11003        let s = parse("CREATE TABLE t (blob BYTES)");
11004        let Statement::CreateTable(c) = s else {
11005            panic!("expected CreateTable");
11006        };
11007        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
11008    }
11009
11010    #[test]
11011    fn bytea_round_trip_display() {
11012        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
11013        let again = parse_statement(&original.to_string()).unwrap();
11014        assert_eq!(original, again);
11015    }
11016
11017    // --- v0.9 transactions -------------------------------------------------
11018
11019    #[test]
11020    fn begin_commit_rollback_parse_as_unit_variants() {
11021        assert_eq!(parse("BEGIN"), Statement::Begin);
11022        assert_eq!(parse("COMMIT"), Statement::Commit);
11023        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
11024        // Trailing semicolons accepted too.
11025        assert_eq!(parse("BEGIN;"), Statement::Begin);
11026    }
11027
11028    // --- v1.2: pgvector distance ops + ::vector cast --------------------
11029
11030    #[test]
11031    fn inner_product_binop_parses() {
11032        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
11033        let Statement::Select(s) = s else { panic!() };
11034        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11035            panic!()
11036        };
11037        assert!(matches!(
11038            expr,
11039            Expr::Binary {
11040                op: BinOp::InnerProduct,
11041                ..
11042            }
11043        ));
11044    }
11045
11046    #[test]
11047    fn cosine_distance_binop_parses() {
11048        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
11049        let Statement::Select(s) = s else { panic!() };
11050        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11051            panic!()
11052        };
11053        assert!(matches!(
11054            expr,
11055            Expr::Binary {
11056                op: BinOp::CosineDistance,
11057                ..
11058            }
11059        ));
11060    }
11061
11062    #[test]
11063    fn vector_cast_postfix_wraps_string_literal() {
11064        let s = parse("SELECT '[1,2,3]'::vector FROM t");
11065        let Statement::Select(s) = s else { panic!() };
11066        let SelectItem::Expr { expr, .. } = &s.items[0] else {
11067            panic!()
11068        };
11069        assert!(matches!(
11070            expr,
11071            Expr::Cast {
11072                target: CastTarget::Vector,
11073                ..
11074            }
11075        ));
11076    }
11077
11078    #[test]
11079    fn unsupported_cast_target_errors() {
11080        // v7.37.5 ship triage promoted the parser to accept every
11081        // ident as a `CastTarget::Named(canonical)`; the engine
11082        // surfaces the "unsupported cast target" error at eval
11083        // time when `type_name_to_data_type` can't resolve it.
11084        // Parser-side error now requires a NON-ident after `::`
11085        // (e.g. a punctuation token).
11086        let err = parse_statement("SELECT 1::, FROM t").unwrap_err();
11087        assert!(err.message.contains("expected type ident after `::`"));
11088    }
11089
11090    #[test]
11091    fn tx_statements_round_trip() {
11092        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
11093            let original = parse(q);
11094            let again = parse_statement(&original.to_string()).unwrap();
11095            assert_eq!(original, again);
11096        }
11097    }
11098
11099    #[test]
11100    fn interval_text_parsing_units() {
11101        // v7.37.5 β — three-field shape `(months, days, micros)` so
11102        // `'1 day'` and `'24 hours'` no longer collide (PG parity).
11103        // Single unit.
11104        assert_eq!(parse_interval_text("1 day"), Some((0, 1, 0)));
11105        assert_eq!(
11106            parse_interval_text("24 hours"),
11107            Some((0, 0, 86_400_000_000))
11108        );
11109        assert_eq!(parse_interval_text("1 second"), Some((0, 0, 1_000_000)));
11110        assert_eq!(parse_interval_text("1 month"), Some((1, 0, 0)));
11111        assert_eq!(parse_interval_text("2 years"), Some((24, 0, 0)));
11112        assert_eq!(parse_interval_text("1 week"), Some((0, 7, 0)));
11113        // Compound spans accumulate per-dimension.
11114        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0, 0)));
11115        assert_eq!(
11116            parse_interval_text("1 day 2 hours"),
11117            Some((0, 1, 7_200_000_000))
11118        );
11119        // Negative numbers carry through per-dimension.
11120        assert_eq!(parse_interval_text("-1 day"), Some((0, -1, 0)));
11121        // Bad shapes return None.
11122        assert_eq!(parse_interval_text(""), None);
11123        assert_eq!(parse_interval_text("garbage"), None);
11124        assert_eq!(parse_interval_text("1 fortnight"), None);
11125        assert_eq!(parse_interval_text("1"), None);
11126    }
11127
11128    #[test]
11129    fn interval_literal_roundtrips_via_display() {
11130        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
11131        let s = parsed.to_string();
11132        // Display preserves the original text verbatim.
11133        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
11134        // And re-parsing yields a structurally equal statement.
11135        let again = parse_statement(&s).unwrap();
11136        assert_eq!(parsed, again);
11137    }
11138
11139    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
11140
11141    #[test]
11142    fn parser_recognises_create_publication_bare() {
11143        let s = parse("CREATE PUBLICATION pub_a");
11144        let Statement::CreatePublication(p) = s else {
11145            panic!("expected CreatePublication, got {s:?}")
11146        };
11147        assert_eq!(p.name, "pub_a");
11148        assert_eq!(p.scope, PublicationScope::AllTables);
11149    }
11150
11151    #[test]
11152    fn parser_recognises_create_publication_for_all_tables() {
11153        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
11154        let Statement::CreatePublication(p) = s else {
11155            panic!("expected CreatePublication, got {s:?}")
11156        };
11157        assert_eq!(p.name, "pub_a");
11158        assert_eq!(p.scope, PublicationScope::AllTables);
11159    }
11160
11161    #[test]
11162    fn parser_recognises_drop_publication() {
11163        let s = parse("DROP PUBLICATION pub_a");
11164        let Statement::DropPublication(name) = s else {
11165            panic!("expected DropPublication, got {s:?}")
11166        };
11167        assert_eq!(name, "pub_a");
11168    }
11169
11170    #[test]
11171    fn parser_recognises_for_table_list() {
11172        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
11173        let Statement::CreatePublication(p) = s else {
11174            panic!("expected CreatePublication, got {s:?}")
11175        };
11176        assert_eq!(p.name, "pub_a");
11177        let PublicationScope::ForTables(ts) = p.scope else {
11178            panic!("expected ForTables scope")
11179        };
11180        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
11181    }
11182
11183    #[test]
11184    fn parser_recognises_for_tables_plural() {
11185        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
11186        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
11187        let Statement::CreatePublication(p) = s else {
11188            panic!("expected CreatePublication, got {s:?}")
11189        };
11190        let PublicationScope::ForTables(ts) = p.scope else {
11191            panic!("expected ForTables")
11192        };
11193        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11194    }
11195
11196    #[test]
11197    fn parser_recognises_for_all_tables_except_list() {
11198        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
11199        let Statement::CreatePublication(p) = s else {
11200            panic!()
11201        };
11202        let PublicationScope::AllTablesExcept(ts) = p.scope else {
11203            panic!("expected AllTablesExcept")
11204        };
11205        assert_eq!(ts, alloc::vec!["t1", "t2"]);
11206    }
11207
11208    #[test]
11209    fn parser_rejects_for_table_with_empty_list() {
11210        // `FOR TABLE` with nothing after is a parse error.
11211        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
11212            .expect_err("must error on empty list");
11213        // No specific message asserted — the call falls through to
11214        // expect_ident_like which yields "expected identifier, got …".
11215        assert!(!err.message.is_empty());
11216    }
11217
11218    #[test]
11219    fn parser_recognises_show_publications() {
11220        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
11221        // bare ident in this position, NOT a reserved keyword.
11222        let s = parse("SHOW PUBLICATIONS");
11223        assert!(matches!(s, Statement::ShowPublications));
11224    }
11225
11226    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
11227
11228    #[test]
11229    fn parser_recognises_create_subscription_single_publication() {
11230        let s = parse(
11231            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
11232        );
11233        let Statement::CreateSubscription(c) = s else {
11234            panic!("expected CreateSubscription, got {s:?}")
11235        };
11236        assert_eq!(c.name, "sub_a");
11237        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
11238        assert_eq!(c.publications, alloc::vec!["pub_a"]);
11239    }
11240
11241    #[test]
11242    fn parser_recognises_create_subscription_multi_publication() {
11243        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
11244        let Statement::CreateSubscription(c) = s else {
11245            panic!()
11246        };
11247        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
11248    }
11249
11250    #[test]
11251    fn parser_rejects_create_subscription_missing_connection() {
11252        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
11253            .expect_err("must error on missing CONNECTION");
11254        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
11255    }
11256
11257    #[test]
11258    fn parser_rejects_create_subscription_missing_publication() {
11259        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
11260            .expect_err("must error on missing PUBLICATION");
11261        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
11262    }
11263
11264    #[test]
11265    fn parser_recognises_drop_subscription() {
11266        let s = parse("DROP SUBSCRIPTION sub_a");
11267        let Statement::DropSubscription(name) = s else {
11268            panic!("expected DropSubscription, got {s:?}")
11269        };
11270        assert_eq!(name, "sub_a");
11271    }
11272
11273    #[test]
11274    fn parser_recognises_show_subscriptions() {
11275        let s = parse("SHOW SUBSCRIPTIONS");
11276        assert!(matches!(s, Statement::ShowSubscriptions));
11277    }
11278
11279    #[test]
11280    fn parser_recognises_wait_for_wal_position_no_timeout() {
11281        let s = parse("WAIT FOR WAL POSITION 12345");
11282        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11283            panic!("expected WaitForWalPosition, got {s:?}")
11284        };
11285        assert_eq!(pos, 12345);
11286        assert!(timeout_ms.is_none());
11287    }
11288
11289    #[test]
11290    fn parser_recognises_wait_for_wal_position_with_timeout() {
11291        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
11292        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
11293            panic!()
11294        };
11295        assert_eq!(pos, 67890);
11296        assert_eq!(timeout_ms, Some(5000));
11297    }
11298
11299    #[test]
11300    fn parser_rejects_wait_with_negative_position() {
11301        // The lexer treats `-` as a token; `expect_u64_literal`
11302        // only sees the Integer that follows, so the negative
11303        // arrives as a unary-minus expression at higher levels.
11304        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
11305        // parse error one way or another.
11306        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
11307        assert!(!err.message.is_empty());
11308    }
11309
11310    #[test]
11311    fn parser_recognises_bare_analyze() {
11312        let s = parse("ANALYZE");
11313        assert!(matches!(s, Statement::Analyze(None)));
11314    }
11315
11316    #[test]
11317    fn parser_recognises_analyze_with_table() {
11318        let s = parse("ANALYZE users");
11319        let Statement::Analyze(Some(name)) = s else {
11320            panic!("expected Analyze, got {s:?}")
11321        };
11322        assert_eq!(name, "users");
11323    }
11324
11325    #[test]
11326    fn parser_recognises_analyze_with_quoted_table() {
11327        let s = parse("ANALYZE \"Mixed Case\"");
11328        let Statement::Analyze(Some(name)) = s else {
11329            panic!()
11330        };
11331        assert_eq!(name, "Mixed Case");
11332    }
11333
11334    #[test]
11335    fn parser_rejects_analyze_with_garbage_token() {
11336        let err = parse_statement("ANALYZE 42").expect_err("must error");
11337        assert!(!err.message.is_empty());
11338    }
11339
11340    #[test]
11341    fn analyze_display_roundtrips() {
11342        for sql in ["ANALYZE", "ANALYZE users"] {
11343            let s = parse(sql);
11344            let printed = s.to_string();
11345            let again = parse_statement(&printed)
11346                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11347            assert_eq!(s, again);
11348        }
11349    }
11350
11351    #[test]
11352    fn wait_for_display_roundtrips() {
11353        for sql in [
11354            "WAIT FOR WAL POSITION 12345",
11355            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
11356        ] {
11357            let s = parse(sql);
11358            let printed = s.to_string();
11359            let again = parse_statement(&printed)
11360                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11361            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11362        }
11363    }
11364
11365    #[test]
11366    fn subscription_ddl_display_roundtrips() {
11367        for sql in [
11368            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
11369            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
11370            "DROP SUBSCRIPTION sub_a",
11371            "SHOW SUBSCRIPTIONS",
11372        ] {
11373            let s = parse(sql);
11374            let printed = s.to_string();
11375            let again = parse_statement(&printed)
11376                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11377            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11378        }
11379    }
11380
11381    #[test]
11382    fn parser_drop_dispatches_user_vs_publication() {
11383        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
11384        // tokenises DROP. Both targets must still parse.
11385        let s = parse("DROP USER 'alice'");
11386        let Statement::DropUser(name) = s else {
11387            panic!("expected DropUser, got {s:?}")
11388        };
11389        assert_eq!(name, "alice");
11390        // And DROP PUBLICATION lands the new variant.
11391        let s = parse("DROP PUBLICATION p1");
11392        assert!(matches!(s, Statement::DropPublication(_)));
11393    }
11394
11395    #[test]
11396    fn publication_ddl_display_roundtrips() {
11397        // Every CREATE PUBLICATION variant must Display → parse →
11398        // same AST. v6.1.3 covers all three scope shapes.
11399        for sql in [
11400            "CREATE PUBLICATION pub_a",
11401            "CREATE PUBLICATION pub_a FOR ALL TABLES",
11402            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
11403            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
11404            "DROP PUBLICATION pub_a",
11405            "SHOW PUBLICATIONS",
11406        ] {
11407            let s = parse(sql);
11408            let printed = s.to_string();
11409            let again = parse_statement(&printed)
11410                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11411            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11412        }
11413    }
11414
11415    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
11416
11417    #[test]
11418    fn create_function_returns_trigger_plpgsql_minimal() {
11419        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
11420        let s = parse(sql);
11421        let Statement::CreateFunction(f) = s else {
11422            panic!("expected CreateFunction");
11423        };
11424        assert_eq!(f.name, "noop");
11425        assert!(!f.or_replace);
11426        assert!(f.args.is_empty());
11427        assert!(matches!(f.returns, FunctionReturn::Trigger));
11428        assert_eq!(f.language, "plpgsql");
11429        let FunctionBody::PlPgSql(block) = f.body else {
11430            panic!("expected PlPgSql body");
11431        };
11432        assert_eq!(block.statements.len(), 1);
11433        assert!(matches!(
11434            block.statements[0],
11435            PlPgSqlStmt::Return(ReturnTarget::New)
11436        ));
11437    }
11438
11439    #[test]
11440    fn create_function_or_replace_with_assignment() {
11441        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
11442        // RETURN NEW.
11443        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
11444BEGIN
11445  NEW.search_vector := to_tsvector('english', NEW.subject);
11446  RETURN NEW;
11447END;
11448$$";
11449        let s = parse(sql);
11450        let Statement::CreateFunction(f) = s else {
11451            panic!("expected CreateFunction");
11452        };
11453        assert!(f.or_replace);
11454        let FunctionBody::PlPgSql(block) = &f.body else {
11455            panic!("expected PlPgSql body");
11456        };
11457        assert_eq!(block.statements.len(), 2);
11458        // First statement: NEW.search_vector := to_tsvector(...)
11459        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
11460            panic!("expected Assign as first stmt");
11461        };
11462        match target {
11463            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
11464            other => panic!("expected NEW.col, got {other:?}"),
11465        }
11466        // Second statement: RETURN NEW
11467        assert!(matches!(
11468            block.statements[1],
11469            PlPgSqlStmt::Return(ReturnTarget::New)
11470        ));
11471    }
11472
11473    #[test]
11474    fn create_trigger_after_insert_or_update() {
11475        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
11476        let s = parse(sql);
11477        let Statement::CreateTrigger(t) = s else {
11478            panic!("expected CreateTrigger");
11479        };
11480        assert_eq!(t.name, "tg");
11481        assert_eq!(t.table, "messages");
11482        assert_eq!(t.timing, TriggerTiming::After);
11483        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
11484        assert_eq!(t.for_each, TriggerForEach::Row);
11485        assert_eq!(t.function, "update_sv");
11486    }
11487
11488    #[test]
11489    fn create_trigger_before_delete_execute_procedure_alias() {
11490        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
11491        let sql =
11492            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
11493        let s = parse(sql);
11494        let Statement::CreateTrigger(t) = s else {
11495            panic!("expected CreateTrigger");
11496        };
11497        assert_eq!(t.timing, TriggerTiming::Before);
11498        assert_eq!(t.events, vec![TriggerEvent::Delete]);
11499    }
11500
11501    #[test]
11502    fn drop_trigger_if_exists_round_trips() {
11503        // No parser support for DROP TRIGGER yet — added in v7.12.5
11504        // alongside the broader DROP …{IF EXISTS} cleanup. The
11505        // AST + Display impls are in place so we round-trip via
11506        // construction:
11507        let s = Statement::DropTrigger {
11508            name: "tg".into(),
11509            table: "messages".into(),
11510            if_exists: true,
11511        };
11512        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
11513    }
11514
11515    #[test]
11516    fn trigger_ddl_display_roundtrips_through_parser() {
11517        // CREATE TRIGGER + its referenced CREATE FUNCTION must
11518        // Display → parse → same AST (modulo PL/pgSQL body
11519        // formatting which is parser-canonicalised).
11520        for sql in [
11521            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
11522            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
11523        ] {
11524            let s = parse(sql);
11525            let printed = s.to_string();
11526            let again = parse_statement(&printed)
11527                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
11528            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
11529        }
11530    }
11531}