Skip to main content

spg_sql/
parser.rs

1//! Recursive-descent parser with a Pratt (precedence-climbing) sub-parser for
2//! expressions.
3//!
4//! Precedence (lowest → highest binding):
5//! `OR` (1) `<` `AND` (2) `<` `NOT` unary (3) `<`
6//! comparisons `=` `<>` `<` `<=` `>` `>=` (4) `<`
7//! `+` `-` (5) `<` `*` `/` (6) `<` unary `-` (7) `<` parens / atom.
8//!
9//! This matches PG's behaviour for the operators we support — e.g. `NOT a = b`
10//! parses as `NOT (a = b)` and `-a * b` as `(-a) * b`.
11
12use alloc::boxed::Box;
13use alloc::format;
14use alloc::string::{String, ToString};
15use alloc::vec;
16use alloc::vec::Vec;
17use core::fmt;
18use core::mem;
19
20use crate::ast::{
21    AssignTarget, BinOp, CastTarget, Collation, ColumnDef, ColumnName, ColumnTypeName,
22    CreateFunctionStatement, CreateIndexStatement, CreatePublicationStatement,
23    CreateSubscriptionStatement, CreateTableStatement, CreateTriggerStatement, Expr, ExtractField,
24    FkAction, ForeignKeyConstraint, FrameBound, FrameKind, FromClause, FromJoin, FunctionArg,
25    FunctionArgMode, FunctionArgType, FunctionBody, FunctionReturn, IndexMethod, InsertStatement,
26    JoinKind, Literal, NullTreatment, OrderBy, PlPgSqlBlock, PlPgSqlDeclare, PlPgSqlStmt,
27    PublicationScope, RaiseLevel, RangeKindAst, ReturnTarget, SelectItem, SelectStatement,
28    Statement, TableRef, TriggerEvent, TriggerForEach, TriggerTiming, UnOp, UnionKind, VecEncoding,
29    WindowFrame,
30};
31use crate::lexer::{self, LexError, Token};
32
33/// v7.14.0 — true when the leading keyword of a top-level
34/// statement is one of the dump-emitted DDL forms SPG accepts
35/// as a no-op (no behavioural effect on the single-schema /
36/// single-database model). These statements are consumed up to
37/// the next `;` / EOF and returned as `Statement::Empty`.
38fn is_dump_noise_statement(lc: &str) -> bool {
39    matches!(
40        lc,
41        // Object comments / privileges / ownership — none of
42        // these change schema semantics on SPG.
43        "comment"
44            | "grant"
45            | "revoke"
46            // MySQL bulk-load brackets.
47            | "lock"
48            | "unlock"
49            // MySQL OPTIMIZE / ANALYZE TABLE / CHECK TABLE
50            // diagnostics that pg_dump-style tools also emit
51            // post-restore.
52            | "optimize"
53            | "check"
54            | "use"
55            // PG psql backslash meta-commands that newer
56            // pg_dump versions emit unescaped (\restrict /
57            // \unrestrict). Real psql intercepts these; SPG's
58            // PG-wire sees them as raw text.
59            | "\\restrict"
60            | "\\unrestrict"
61            // v7.17.0 Phase 4.1 — MySQL `DELIMITER //` and
62            // `DELIMITER ;` directives. Technically client-side
63            // (the `mysql` CLI uses them to set the statement
64            // terminator), not SQL — but mysqldump and stored-
65            // procedure scripts emit them inline. SPG's parser
66            // sees one statement at a time and doesn't care
67            // about the terminator, so consume DELIMITER lines
68            // as Empty.
69            | "delimiter"
70    )
71}
72
73/// v7.9.22 — recognise pgvector / SPG vector-index opclass names
74/// in CREATE INDEX. SPG's HNSW already routes by query operator;
75/// the opclass is accepted for `pg_dump` compatibility (mailrs
76/// migration follow-up G5).
77/// v7.13.0 — extended to recognise PG built-in / pg_trgm opclasses
78/// (mailrs round-5 G5). These are tokens-only acceptance — SPG
79/// doesn't change index behaviour based on them.
80fn is_vector_opclass_name(name: &str) -> bool {
81    let lc = name.to_ascii_lowercase();
82    matches!(
83        lc.as_str(),
84        "vector_cosine_ops"
85            | "vector_l2_ops"
86            | "vector_ip_ops"
87            | "halfvec_cosine_ops"
88            | "halfvec_l2_ops"
89            | "halfvec_ip_ops"
90            | "sq8_cosine_ops"
91            | "sq8_l2_ops"
92            | "sq8_ip_ops"
93            // pg_trgm — trigram operator class. SPG's GIN index
94            // already uses tsvector tokens; trigram-style LIKE
95            // pattern matching still routes through a sequential
96            // scan, but the opclass name is accepted so PG schemas
97            // load.
98            | "gin_trgm_ops"
99            | "gist_trgm_ops"
100            // PG built-in btree opclasses occasionally appear in
101            // pg_dump output for column types with multiple
102            // sort orders (text_pattern_ops, varchar_pattern_ops,
103            // bpchar_pattern_ops).
104            | "text_pattern_ops"
105            | "varchar_pattern_ops"
106            | "bpchar_pattern_ops"
107            | "int4_ops"
108            | "int8_ops"
109            | "text_ops"
110    )
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ParseError {
115    pub message: String,
116    /// Index into the token stream where parsing tripped. Not a byte offset.
117    pub token_pos: usize,
118}
119
120impl fmt::Display for ParseError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(
123            f,
124            "parse error at token #{}: {}",
125            self.token_pos, self.message
126        )
127    }
128}
129
130impl From<LexError> for ParseError {
131    fn from(e: LexError) -> Self {
132        Self {
133            message: format!("lex: {e}"),
134            token_pos: 0,
135        }
136    }
137}
138
139/// v7.9.30 — parse a single expression (no trailing junk). Used by
140/// the engine to re-hydrate stored partial-index / unique-index
141/// predicates from their canonical Display form. The same Pratt
142/// parser the statement path uses; this entry point just skips the
143/// statement dispatch.
144pub fn parse_expression(input: &str) -> Result<Expr, ParseError> {
145    let tokens = lexer::tokenize(input)?;
146    let mut p = Parser::new(tokens);
147    let expr = p.parse_expr(0)?;
148    p.expect_eof()?;
149    Ok(expr)
150}
151
152/// Parse exactly one statement, swallow an optional trailing `;`, and require
153/// the token stream to end there.
154pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
155    let tokens = lexer::tokenize(input)?;
156    let mut p = Parser::new(tokens);
157    let stmt = p.parse_one_statement()?;
158    if matches!(p.peek(), Token::Semicolon) {
159        p.advance();
160    }
161    p.expect_eof()?;
162    Ok(stmt)
163}
164
165struct Parser {
166    tokens: Vec<Token>,
167    pos: usize,
168}
169
170impl Parser {
171    fn new(tokens: Vec<Token>) -> Self {
172        Self { tokens, pos: 0 }
173    }
174
175    fn peek(&self) -> &Token {
176        // tokens always ends with Eof; pos is clamped in advance().
177        &self.tokens[self.pos]
178    }
179
180    fn advance(&mut self) -> Token {
181        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
182        if self.pos + 1 < self.tokens.len() {
183            self.pos += 1;
184        }
185        t
186    }
187
188    fn err(&self, message: String) -> ParseError {
189        ParseError {
190            message,
191            token_pos: self.pos,
192        }
193    }
194
195    fn expect_eof(&self) -> Result<(), ParseError> {
196        if matches!(self.peek(), Token::Eof) {
197            Ok(())
198        } else {
199            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
200        }
201    }
202
203    /// v7.14.0 — swallow every token up to (but not including) the
204    /// next semicolon / EOF. Used by the dump-noise dispatcher
205    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
206    /// etc. without modeling each grammar.
207    fn consume_until_statement_boundary(&mut self) {
208        loop {
209            match self.peek() {
210                Token::Semicolon | Token::Eof => return,
211                _ => self.advance(),
212            };
213        }
214    }
215
216    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
217        let first = match self.advance() {
218            Token::Ident(s) | Token::QuotedIdent(s) => s,
219            other => {
220                return Err(ParseError {
221                    message: format!("expected identifier, got {other:?}"),
222                    token_pos: self.pos.saturating_sub(1),
223                });
224            }
225        };
226        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
227        // qualify every name with `public.` (and pg_catalog.* for
228        // functions); SPG is single-schema so we discard the
229        // prefix and return only the trailing ident. Same shape
230        // also handles MySQL `db.tbl` cross-database refs (SPG
231        // ignores the db part).
232        if matches!(self.peek(), Token::Dot) {
233            self.advance();
234            match self.advance() {
235                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
236                other => {
237                    return Err(ParseError {
238                        message: format!("expected identifier after '{first}.', got {other:?}"),
239                        token_pos: self.pos.saturating_sub(1),
240                    });
241                }
242            }
243        }
244        Ok(first)
245    }
246
247    #[allow(clippy::too_many_lines)]
248    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
249        // v7.14.0 — empty / comment-only / semicolon-only input
250        // (after the lexer strips line + block + MySQL
251        // conditional comments) lands as Statement::Empty.
252        // pg_dump and mysqldump emit several wrappers that
253        // collapse to nothing after stripping (`/*!40101 SET …
254        // */;`, blank lines between statements); the engine
255        // returns CommandOk no-op so the dump loads cleanly.
256        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
257            return Ok(Statement::Empty);
258        }
259        // v7.14.0 — pg_dump / mysqldump "noise" statements:
260        // catalog / metadata DDL that has no behavioural effect
261        // on SPG's single-schema, single-database, single-user
262        // model. Consume the whole statement up to the next
263        // semicolon / EOF and return Empty. This is broader than
264        // the per-keyword DROP / SET / COMMENT arms but lets the
265        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
266        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
267        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
268        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
269            let lc = s.to_ascii_lowercase();
270            if is_dump_noise_statement(&lc) {
271                self.consume_until_statement_boundary();
272                return Ok(Statement::Empty);
273            }
274        }
275        match self.peek() {
276            Token::Select => self.parse_select_stmt(),
277            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
278            // body is a dollar-quoted plpgsql block (lexer already
279            // collapsed `$$…$$` into a single Token::String).
280            // v7.16.2 — mailrs round-10 A.2: parse the body as a
281            // real PlPgSqlBlock so the engine can EXECUTE it at
282            // top level instead of silently swallowing. Pre-
283            // v7.16.2 the parser threw the body away and the
284            // engine returned CommandOk for the entire DO; that
285            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
286            // $$` into a SEV-1 silent no-op (the IF + the rename
287            // were both invisible — mailrs's migrate-042 didn't
288            // actually run). Now the body parses + executes;
289            // EmbeddedSql inside the block runs immediately
290            // against the engine (not deferred — we're at top
291            // level, not inside a trigger row-write loop).
292            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
293                self.advance();
294                let body_text = match self.advance() {
295                    Token::String(s) => s,
296                    other => {
297                        return Err(self.err(alloc::format!(
298                            "expected dollar-quoted body after DO, got {other:?}"
299                        )));
300                    }
301                };
302                // Optional `LANGUAGE <name>` trailer (idents only).
303                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
304                    self.advance();
305                    let _ = self.expect_ident_like()?;
306                }
307                // Parse the body — same shape CREATE FUNCTION
308                // uses for trigger function bodies. If the body
309                // doesn't parse cleanly we surface the error
310                // (better than silent no-op).
311                let block = parse_plpgsql_body(&body_text)?;
312                Ok(Statement::DoBlock(block))
313            }
314            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
315            // WITH isn't a reserved token in our lexer — comes through
316            // as `Token::Ident("with")` (case-insensitive).
317            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
318                self.advance();
319                self.parse_with_cte_then_select()
320            }
321            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
322            // an identifier — not a reserved keyword.
323            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
324                self.advance();
325                let mut analyze = false;
326                let mut suggest = false;
327                // v6.8.3 — `EXPLAIN (SUGGEST)` opt-in.
328                if matches!(self.peek(), Token::LParen) {
329                    self.advance();
330                    let opt = match self.peek().clone() {
331                        Token::Ident(s) | Token::QuotedIdent(s) => s,
332                        other => {
333                            return Err(self.err(format!(
334                                "expected option keyword inside EXPLAIN (…), got {other:?}"
335                            )));
336                        }
337                    };
338                    if !opt.eq_ignore_ascii_case("suggest") {
339                        return Err(self.err(format!(
340                            "unknown EXPLAIN option {opt:?}; v6.8.3 supports SUGGEST"
341                        )));
342                    }
343                    self.advance();
344                    if !matches!(self.peek(), Token::RParen) {
345                        return Err(self.err(format!(
346                            "expected ')' after EXPLAIN option, got {:?}",
347                            self.peek()
348                        )));
349                    }
350                    self.advance();
351                    suggest = true;
352                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
353                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
354                {
355                    self.advance();
356                    analyze = true;
357                }
358                let inner = self.parse_select_stmt()?;
359                let Statement::Select(s) = inner else {
360                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
361                };
362                Ok(Statement::Explain(crate::ast::ExplainStatement {
363                    analyze,
364                    inner: Box::new(s),
365                    suggest,
366                }))
367            }
368            Token::Create => self.parse_create_stmt(),
369            Token::Insert => self.parse_insert_stmt(),
370            Token::Begin => {
371                self.advance();
372                Ok(Statement::Begin)
373            }
374            Token::Commit => {
375                self.advance();
376                Ok(Statement::Commit)
377            }
378            Token::Rollback => {
379                self.advance();
380                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
381                // savepoint without ending the transaction. Bare
382                // `ROLLBACK` drops the whole TX.
383                if matches!(self.peek(), Token::To) {
384                    self.advance();
385                    if matches!(self.peek(), Token::Savepoint) {
386                        self.advance();
387                    }
388                    let name = self.expect_ident_like()?;
389                    Ok(Statement::RollbackToSavepoint(name))
390                } else {
391                    Ok(Statement::Rollback)
392                }
393            }
394            Token::Savepoint => {
395                self.advance();
396                let name = self.expect_ident_like()?;
397                Ok(Statement::Savepoint(name))
398            }
399            Token::Release => {
400                self.advance();
401                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
402                // is optional in standard SQL.
403                if matches!(self.peek(), Token::Savepoint) {
404                    self.advance();
405                }
406                let name = self.expect_ident_like()?;
407                Ok(Statement::ReleaseSavepoint(name))
408            }
409            Token::Show => {
410                self.advance();
411                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
412                // v6.1.2 promoted TABLES to a reserved keyword (for
413                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
414                // arrives as `Token::Tables` rather than a bare ident.
415                // USERS / COLUMNS remain bare idents.
416                let target = match self.advance() {
417                    Token::Tables => "tables".to_string(),
418                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
419                    // keyword token; recognise it as the SHOW CREATE
420                    // dispatch keyword too.
421                    Token::Create => "create".to_string(),
422                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
423                    // keyword too; let SHOW INDEX FROM parse.
424                    Token::Index => "index".to_string(),
425                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
426                    other => {
427                        return Err(self.err(format!(
428                            "expected SHOW target, got {other:?}"
429                        )));
430                    }
431                };
432                match target.as_str() {
433                    "tables" => Ok(Statement::ShowTables),
434                    "users" => Ok(Statement::ShowUsers),
435                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
436                    // TABLE <t>` returns a 2-column row: (Table,
437                    // Create Table). mysqldump emits this for every
438                    // table at scrape time; without it the dump
439                    // round-trip stalls.
440                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
441                    // FROM <t>` (also spelled `SHOW INDEX` and
442                    // `SHOW KEYS`). admin / mysqldump probes use
443                    // it to list per-table indexes.
444                    "indexes" | "index" | "keys" => {
445                        if !matches!(self.peek(), Token::From) {
446                            return Err(self.err(format!(
447                                "expected FROM after SHOW INDEXES, got {:?}",
448                                self.peek()
449                            )));
450                        }
451                        self.advance();
452                        let table = self.expect_ident_like()?;
453                        Ok(Statement::ShowIndexes(table))
454                    }
455                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
456                    // `SHOW VARIABLES`. Both return a 2-column row
457                    // set listing server-side state; clients probe
458                    // them at connect time.
459                    "status" => Ok(Statement::ShowStatus),
460                    "variables" => Ok(Statement::ShowVariables),
461                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
462                    "processlist" => Ok(Statement::ShowProcesslist),
463                    "create" => {
464                        // SHOW CREATE TABLE / VIEW / DATABASE — only
465                        // TABLE is supported in v7.17.
466                        let kind = match self.advance() {
467                            Token::Ident(s) | Token::QuotedIdent(s) => s,
468                            Token::Table => "table".to_string(),
469                            other => {
470                                return Err(self.err(format!(
471                                    "expected TABLE after SHOW CREATE, got {other:?}"
472                                )));
473                            }
474                        };
475                        if !kind.eq_ignore_ascii_case("table") {
476                            return Err(self.err(format!(
477                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
478                            )));
479                        }
480                        let name = self.expect_ident_like()?;
481                        Ok(Statement::ShowCreateTable(name))
482                    }
483                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
484                    // (and `SHOW SCHEMAS` alias). The mysql client uses
485                    // it to populate the database selector at connect
486                    // time; without it `mysql -p` errors before the
487                    // first user query.
488                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
489                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
490                    // keyword on its own; it lands here as a bare
491                    // ident. Returning all publications + their
492                    // scope summary.
493                    "publications" => Ok(Statement::ShowPublications),
494                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
495                    "subscriptions" => Ok(Statement::ShowSubscriptions),
496                    "columns" => {
497                        if !matches!(self.peek(), Token::From) {
498                            return Err(self.err(format!(
499                                "expected FROM after SHOW COLUMNS, got {:?}",
500                                self.peek()
501                            )));
502                        }
503                        self.advance();
504                        let table = self.expect_ident_like()?;
505                        Ok(Statement::ShowColumns(table))
506                    }
507                    other => Err(self.err(format!(
508                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
509                    ))),
510                }
511            }
512            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
513            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
514            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
515            // arrived as a bare ident; tokenising it dedicatedly
516            // keeps the dispatch tree small.
517            Token::Drop => {
518                self.advance();
519                match self.peek() {
520                    Token::Publication => {
521                        self.advance();
522                        let name = self.expect_ident_or_string()?;
523                        Ok(Statement::DropPublication(name))
524                    }
525                    Token::Subscription => {
526                        self.advance();
527                        let name = self.expect_ident_or_string()?;
528                        Ok(Statement::DropSubscription(name))
529                    }
530                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
531                        self.advance();
532                        let name = self.expect_ident_or_string()?;
533                        Ok(Statement::DropUser(name))
534                    }
535                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
536                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
537                        self.advance();
538                        let if_exists = self.consume_if_exists();
539                        let name = self.expect_ident_like()?;
540                        // ON <table>
541                        if !matches!(self.peek(), Token::On) {
542                            return Err(self.err(alloc::format!(
543                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
544                                self.peek()
545                            )));
546                        }
547                        self.advance();
548                        let table = self.expect_ident_like()?;
549                        Ok(Statement::DropTrigger {
550                            name,
551                            table,
552                            if_exists,
553                        })
554                    }
555                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
556                    // v7.12.4 ignores any optional arg-list (signature-
557                    // based overload disambiguation lands in v7.12.5+).
558                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
559                        self.advance();
560                        let if_exists = self.consume_if_exists();
561                        let name = self.expect_ident_like()?;
562                        // Optional `()` — consume + discard.
563                        if matches!(self.peek(), Token::LParen) {
564                            self.advance();
565                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
566                            let mut depth = 1usize;
567                            while depth > 0 {
568                                match self.peek() {
569                                    Token::LParen => depth += 1,
570                                    Token::RParen => depth -= 1,
571                                    Token::Eof => {
572                                        return Err(self.err(alloc::format!(
573                                            "unterminated arg list in DROP FUNCTION {name:?}"
574                                        )));
575                                    }
576                                    _ => {}
577                                }
578                                self.advance();
579                            }
580                        }
581                        Ok(Statement::DropFunction { name, if_exists })
582                    }
583                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
584                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
585                    // emit DROP TABLE IF EXISTS at the head of every
586                    // CREATE TABLE block so re-importing a dump
587                    // overwrites prior state. SPG accepts and removes
588                    // matching tables; CASCADE/RESTRICT trailers
589                    // accepted silently.
590                    Token::Table => {
591                        self.advance();
592                        let if_exists = self.consume_if_exists();
593                        let mut names: Vec<String> = Vec::new();
594                        loop {
595                            names.push(self.expect_ident_like()?);
596                            if matches!(self.peek(), Token::Comma) {
597                                self.advance();
598                                continue;
599                            }
600                            break;
601                        }
602                        if matches!(
603                            self.peek(),
604                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
605                                || s.eq_ignore_ascii_case("restrict")
606                        ) {
607                            self.advance();
608                        }
609                        Ok(Statement::DropTable { names, if_exists })
610                    }
611                    // v7.14.0 — DROP INDEX [IF EXISTS] name
612                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
613                    // for partial-index renames and pgvector
614                    // migrations. SPG removes the matching index;
615                    // IF EXISTS makes the drop idempotent.
616                    Token::Index => {
617                        self.advance();
618                        let if_exists = self.consume_if_exists();
619                        let name = self.expect_ident_like()?;
620                        if matches!(
621                            self.peek(),
622                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
623                                || s.eq_ignore_ascii_case("restrict")
624                        ) {
625                            self.advance();
626                        }
627                        Ok(Statement::DropIndex { name, if_exists })
628                    }
629                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
630                    // [CASCADE|RESTRICT]. SPG is single-database;
631                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
632                    // name [, name…] [CASCADE | RESTRICT]. Real
633                    // unregister (was silent no-op pre-v7.17).
634                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
635                        self.advance();
636                        let if_exists = self.consume_if_exists();
637                        let mut names = vec![self.expect_ident_like()?];
638                        while matches!(self.peek(), Token::Comma) {
639                            self.advance();
640                            names.push(self.expect_ident_like()?);
641                        }
642                        if matches!(
643                            self.peek(),
644                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
645                                || s.eq_ignore_ascii_case("restrict")
646                        ) {
647                            self.advance();
648                        }
649                        Ok(Statement::DropSchema { names, if_exists })
650                    }
651                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
652                    // name [, name…] [CASCADE|RESTRICT].
653                    Token::Ident(s) | Token::QuotedIdent(s)
654                        if s.eq_ignore_ascii_case("type") =>
655                    {
656                        self.advance();
657                        let if_exists = self.consume_if_exists();
658                        let mut names = vec![self.expect_ident_like()?];
659                        while matches!(self.peek(), Token::Comma) {
660                            self.advance();
661                            names.push(self.expect_ident_like()?);
662                        }
663                        if matches!(
664                            self.peek(),
665                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
666                                || s.eq_ignore_ascii_case("restrict")
667                        ) {
668                            self.advance();
669                        }
670                        Ok(Statement::DropType { names, if_exists })
671                    }
672                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
673                    // name [, name…] [CASCADE|RESTRICT].
674                    Token::Ident(s) | Token::QuotedIdent(s)
675                        if s.eq_ignore_ascii_case("domain") =>
676                    {
677                        self.advance();
678                        let if_exists = self.consume_if_exists();
679                        let mut names = vec![self.expect_ident_like()?];
680                        while matches!(self.peek(), Token::Comma) {
681                            self.advance();
682                            names.push(self.expect_ident_like()?);
683                        }
684                        if matches!(
685                            self.peek(),
686                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
687                                || s.eq_ignore_ascii_case("restrict")
688                        ) {
689                            self.advance();
690                        }
691                        Ok(Statement::DropDomain { names, if_exists })
692                    }
693                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
694                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
695                    Token::Ident(s) | Token::QuotedIdent(s)
696                        if s.eq_ignore_ascii_case("materialized") =>
697                    {
698                        self.advance();
699                        let nxt = self.peek().clone();
700                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
701                        {
702                            return Err(self.err(alloc::format!(
703                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
704                            )));
705                        }
706                        self.advance();
707                        let if_exists = self.consume_if_exists();
708                        let mut names = vec![self.expect_ident_like()?];
709                        while matches!(self.peek(), Token::Comma) {
710                            self.advance();
711                            names.push(self.expect_ident_like()?);
712                        }
713                        if matches!(
714                            self.peek(),
715                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
716                                || s.eq_ignore_ascii_case("restrict")
717                        ) {
718                            self.advance();
719                        }
720                        Ok(Statement::DropMaterializedView { names, if_exists })
721                    }
722                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
723                    // name [, name…] [CASCADE|RESTRICT].
724                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
725                        self.advance();
726                        let if_exists = self.consume_if_exists();
727                        let mut names = vec![self.expect_ident_like()?];
728                        while matches!(self.peek(), Token::Comma) {
729                            self.advance();
730                            names.push(self.expect_ident_like()?);
731                        }
732                        if matches!(
733                            self.peek(),
734                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
735                                || s.eq_ignore_ascii_case("restrict")
736                        ) {
737                            self.advance();
738                        }
739                        Ok(Statement::DropView { names, if_exists })
740                    }
741                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
742                    // [CASCADE|RESTRICT]. Real removal from catalog
743                    // (was a silent no-op pre-v7.17).
744                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
745                        self.advance();
746                        let if_exists = self.consume_if_exists();
747                        let mut names = vec![self.expect_ident_like()?];
748                        while matches!(self.peek(), Token::Comma) {
749                            self.advance();
750                            names.push(self.expect_ident_like()?);
751                        }
752                        if matches!(
753                            self.peek(),
754                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
755                                || s.eq_ignore_ascii_case("restrict")
756                        ) {
757                            self.advance();
758                        }
759                        Ok(Statement::DropSequence { names, if_exists })
760                    }
761                    other => Err(self.err(format!(
762                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
763                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
764                    ))),
765                }
766            }
767            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
768            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
769                self.advance();
770                let nxt = self.peek().clone();
771                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
772                {
773                    return Err(self.err(alloc::format!(
774                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
775                    )));
776                }
777                self.advance();
778                let nxt2 = self.peek().clone();
779                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
780                {
781                    return Err(self.err(alloc::format!(
782                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
783                    )));
784                }
785                self.advance();
786                let name = self.expect_ident_like()?;
787                let with_data = self.parse_optional_with_data(true)?;
788                Ok(Statement::RefreshMaterializedView { name, with_data })
789            }
790            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
791                self.advance();
792                self.parse_update_after_keyword()
793            }
794            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
795                self.advance();
796                self.parse_delete_after_keyword()
797            }
798            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
799            // ALTER is not a reserved keyword in the lexer — handled
800            // as a bare ident here.
801            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
802                self.advance();
803                self.parse_alter_after_keyword()
804            }
805            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
806            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
807            // additions needed.
808            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
809                self.advance();
810                self.parse_wait_after_keyword()
811            }
812            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
813            // Bare ANALYZE → analyse every user table; ANALYZE
814            // <name> → re-stats one. The argument is an optional
815            // ident (or quoted ident); anything else is a parse
816            // error.
817            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
818            // `WHERE` filter (carved out per V6_7_DESIGN.md
819            // STABILITY). Lex order: identifier "compact" → "cold"
820            // → "segments". Anything else after `COMPACT` is a
821            // parse error.
822            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
823                self.advance();
824                let next = self.peek().clone();
825                let cold = match next {
826                    Token::Ident(s) | Token::QuotedIdent(s) => s,
827                    _ => {
828                        return Err(
829                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
830                        );
831                    }
832                };
833                if !cold.eq_ignore_ascii_case("cold") {
834                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
835                }
836                self.advance();
837                let next = self.peek().clone();
838                let segments = match next {
839                    Token::Ident(s) | Token::QuotedIdent(s) => s,
840                    _ => {
841                        return Err(self.err(format!(
842                            "expected SEGMENTS after COMPACT COLD, got {:?}",
843                            self.peek()
844                        )));
845                    }
846                };
847                if !segments.eq_ignore_ascii_case("segments") {
848                    return Err(self.err(format!(
849                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
850                    )));
851                }
852                self.advance();
853                Ok(Statement::CompactColdSegments)
854            }
855            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
856            // Parsed as a case-insensitive identifier since MERGE
857            // isn't a reserved lexer keyword (collides with the
858            // mysqldump `ALGORITHM = MERGE` view clause if it
859            // were); the inner parser drives the rest of the
860            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
861            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
862                self.advance();
863                self.parse_merge_after_keyword()
864            }
865            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
866                self.advance();
867                let target = match self.peek() {
868                    Token::Eof | Token::Semicolon => None,
869                    Token::Ident(_) | Token::QuotedIdent(_) => {
870                        Some(self.expect_ident_like()?)
871                    }
872                    other => {
873                        return Err(self.err(format!(
874                            "expected table name or end of statement after ANALYZE, got {other:?}"
875                        )));
876                    }
877                };
878                Ok(Statement::Analyze(target))
879            }
880            // v7.12.1 — `SET <name> [TO|=] <value>`. The
881            // `default_text_search_config` parameter is consumed
882            // by the FTS function dispatcher; other parameter
883            // names are recorded but treated as a no-op so PG
884            // dump output loads.
885            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
886                self.advance();
887                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
888                // — accept and ignore. MySQL adds `SET GLOBAL` too
889                // (and the alias `SET @@global.name = …` which the
890                // SessionVar path handles).
891                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"))
892                {
893                    self.advance();
894                }
895                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
896                // <collation>]` — change the connection client
897                // charset. SPG stores UTF-8 always and orders
898                // bytewise; accept as a no-op.
899                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
900                {
901                    self.advance();
902                    // Charset ident-or-string.
903                    if matches!(
904                        self.peek(),
905                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
906                    ) {
907                        self.advance();
908                    }
909                    // Optional `COLLATE <name>`.
910                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
911                    {
912                        self.advance();
913                        if matches!(
914                            self.peek(),
915                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
916                        ) {
917                            self.advance();
918                        }
919                    }
920                    return Ok(Statement::Empty);
921                }
922                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
923                // { DEFAULT | '<role>' | <ident> }` (mailrs
924                // round-10 A.1). pg_dump preamble emits the
925                // `DEFAULT` form to reset session authorization;
926                // SPG has no role system so this is a strict
927                // no-op. PG also accepts `RESET SESSION
928                // AUTHORIZATION` (handled by the RESET parser
929                // elsewhere). Reference:
930                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
931                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
932                {
933                    self.advance(); // AUTHORIZATION
934                    match self.peek().clone() {
935                        Token::Default => {
936                            self.advance();
937                        }
938                        Token::String(_)
939                        | Token::Ident(_)
940                        | Token::QuotedIdent(_) => {
941                            self.advance();
942                        }
943                        other => {
944                            return Err(self.err(alloc::format!(
945                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
946                            )));
947                        }
948                    }
949                    return Ok(Statement::Empty);
950                }
951                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
952                // alias — same accept-as-no-op as SET NAMES.
953                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
954                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
955                {
956                    self.advance(); // CHARACTER
957                    self.advance(); // SET
958                    if matches!(
959                        self.peek(),
960                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
961                    ) {
962                        self.advance();
963                    }
964                    return Ok(Statement::Empty);
965                }
966                // v7.14.0 — multi-assignment form
967                // `SET a = 1, b = 2, …`. Single-assignment is the
968                // 1-element case. Each LHS may be a regular ident
969                // or a SessionVar (`@VAR` / `@@VAR`).
970                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
971                loop {
972                    let lhs = match self.peek().clone() {
973                        Token::SessionVar(s) => {
974                            self.advance();
975                            s
976                        }
977                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
978                        other => {
979                            return Err(self.err(format!(
980                                "expected parameter name after SET, got {other:?}"
981                            )));
982                        }
983                    };
984                    // Accept either `=` or the bare `TO` keyword.
985                    match self.peek() {
986                        Token::Eq => {
987                            self.advance();
988                        }
989                        Token::To => {
990                            self.advance();
991                        }
992                        other => {
993                            return Err(self.err(format!(
994                                "expected `=` or TO after SET {lhs}, got {other:?}"
995                            )));
996                        }
997                    }
998                    let value = self.parse_set_value()?;
999                    pairs.push((lhs, value));
1000                    if matches!(self.peek(), Token::Comma) {
1001                        self.advance();
1002                        continue;
1003                    }
1004                    break;
1005                }
1006                if pairs.len() == 1 {
1007                    let (name, value) = pairs.into_iter().next().unwrap();
1008                    Ok(Statement::SetParameter { name, value })
1009                } else {
1010                    Ok(Statement::SetParameterList(pairs))
1011                }
1012            }
1013            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1014            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1015                self.advance();
1016                match self.peek().clone() {
1017                    Token::All => {
1018                        self.advance();
1019                        Ok(Statement::ResetParameter(None))
1020                    }
1021                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1022                        self.advance();
1023                        Ok(Statement::ResetParameter(None))
1024                    }
1025                    _ => {
1026                        let name = self.parse_set_param_name()?;
1027                        Ok(Statement::ResetParameter(Some(name)))
1028                    }
1029                }
1030            }
1031            other => Err(self.err(format!(
1032                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1033                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1034            ))),
1035        }
1036    }
1037
1038    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1039        debug_assert!(matches!(self.peek(), Token::Create));
1040        self.advance();
1041        match self.peek() {
1042            Token::Table => self.parse_create_table_stmt_after_create(),
1043            Token::Index => self.parse_create_index_stmt_after_create(false),
1044            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1045            // The `UNIQUE` modifier turns a partial index into a
1046            // partial-uniqueness invariant (only rows matching the
1047            // WHERE predicate are checked for duplicates). mailrs
1048            // K1 (3 hits: email_templates default, calendar_events
1049            // master, calendar_events instance).
1050            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1051                self.advance();
1052                if !matches!(self.peek(), Token::Index) {
1053                    return Err(self.err(alloc::format!(
1054                        "expected INDEX after CREATE UNIQUE, got {:?}",
1055                        self.peek()
1056                    )));
1057                }
1058                self.parse_create_index_stmt_after_create(true)
1059            }
1060            Token::Publication => {
1061                self.advance();
1062                self.parse_create_publication_after_keyword()
1063            }
1064            Token::Subscription => {
1065                self.advance();
1066                self.parse_create_subscription_after_keyword()
1067            }
1068            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1069            // USER isn't a reserved keyword — we look for the bare
1070            // identifier so the lexer doesn't have to grow a token.
1071            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1072                self.advance();
1073                self.parse_create_user_after_keyword()
1074            }
1075            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1076            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1077            // no-op. mailrs follow-up F3.
1078            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1079                self.advance();
1080                self.parse_create_extension_after_keyword()
1081            }
1082            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1083            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1084            // optional; absorb it here and forward to the
1085            // per-kind parsers with the flag. OR is a reserved
1086            // keyword token.
1087            Token::Or => {
1088                self.advance();
1089                let next = self.peek();
1090                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1091                    return Err(self.err(alloc::format!(
1092                        "expected REPLACE after CREATE OR, got {next:?}"
1093                    )));
1094                };
1095                if !s2.eq_ignore_ascii_case("replace") {
1096                    return Err(self.err(alloc::format!(
1097                        "expected REPLACE after CREATE OR, got {s2:?}"
1098                    )));
1099                }
1100                self.advance();
1101                self.parse_create_function_or_trigger_after_or_replace(true)
1102            }
1103            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1104                self.advance();
1105                self.parse_create_function_after_keyword(false)
1106            }
1107            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1108                self.advance();
1109                self.parse_create_trigger_after_keyword(false)
1110            }
1111            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1112            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1113                self.advance();
1114                self.parse_create_sequence_after_keyword(false)
1115            }
1116            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1117            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1118                self.advance();
1119                self.parse_create_view_after_keyword(false, false, false)
1120            }
1121            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1122            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1123            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1124            // appear (in any order) between `CREATE` and `VIEW` in
1125            // every mysqldump-emitted view. Pre-2.6 the parser
1126            // rejected the prefix and the customer's whole view
1127            // backup failed on the first view. The hints are pure
1128            // planner / permission metadata; SPG's view-rewrite
1129            // path is semantically equivalent for all three
1130            // algorithms in v7.17 (TEMPTABLE differs only in
1131            // perf for huge views — out of v7.17 scope), and
1132            // DEFINER / SQL SECURITY are pure single-user
1133            // permissioning that SPG ignores by design.
1134            Token::Ident(s) | Token::QuotedIdent(s)
1135                if s.eq_ignore_ascii_case("algorithm")
1136                    || s.eq_ignore_ascii_case("definer")
1137                    || s.eq_ignore_ascii_case("sql") =>
1138            {
1139                self.consume_mysql_view_prefix()?;
1140                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1141                // (in any order, in any combination), the next
1142                // keyword must be VIEW. mysqldump never emits these
1143                // prefixes on non-view statements.
1144                let next = self.peek().clone();
1145                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1146                    if s2.eq_ignore_ascii_case("view"))
1147                {
1148                    self.advance();
1149                    self.parse_create_view_after_keyword(false, false, false)
1150                } else {
1151                    Err(self.err(alloc::format!(
1152                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1153                    )))
1154                }
1155            }
1156            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1157            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1158                self.advance();
1159                self.parse_create_type_after_keyword()
1160            }
1161            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1162            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1163            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1164                self.advance();
1165                self.parse_create_domain_after_keyword()
1166            }
1167            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1168            // name [AUTHORIZATION user]. Real catalog registry
1169            // (was silent-no-op'd pre-v7.17).
1170            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1171                self.advance();
1172                let if_not_exists = self.parse_if_not_exists();
1173                let name = self.expect_ident_like()?;
1174                // Optional `AUTHORIZATION <user>` trailer — accepted,
1175                // ignored (single-user catalog).
1176                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1177                    if s.eq_ignore_ascii_case("authorization"))
1178                {
1179                    self.advance();
1180                    let _ = self.expect_ident_like()?;
1181                }
1182                Ok(Statement::CreateSchema { name, if_not_exists })
1183            }
1184            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1185            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1186                self.advance();
1187                let next = self.peek().clone();
1188                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1189                {
1190                    self.advance();
1191                    self.parse_create_materialized_view_after_keyword()
1192                } else {
1193                    Err(self.err(alloc::format!(
1194                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1195                    )))
1196                }
1197            }
1198            Token::Ident(s) | Token::QuotedIdent(s)
1199                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1200            {
1201                self.advance();
1202                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1203                let next = self.peek().clone();
1204                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1205                {
1206                    self.advance();
1207                    self.parse_create_sequence_after_keyword(true)
1208                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1209                {
1210                    self.advance();
1211                    self.parse_create_view_after_keyword(false, false, true)
1212                } else {
1213                    // TEMP TABLE etc — consume to boundary as noop for now.
1214                    self.consume_until_statement_boundary();
1215                    Ok(Statement::Empty)
1216                }
1217            }
1218            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1219            // BEGIN <body> END`. The body may reference `@var`
1220            // session variables, SET statements, internal `;`
1221            // terminators, etc. SPG has no procedure runtime, so
1222            // consume the whole `CREATE PROCEDURE … END` block as
1223            // a no-op so mysqldump scripts that include stored
1224            // routines load through. The matching-END consumer
1225            // tracks BEGIN/END nesting depth to handle nested
1226            // BEGIN blocks correctly.
1227            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1228                self.consume_mysql_routine_body();
1229                Ok(Statement::Empty)
1230            }
1231            // v7.14.0 — pg_dump / mysqldump emit
1232            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1233            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1234            // SPG is single-schema / single-database; these have
1235            // no behavioural effect, so consume + return Empty.
1236            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1237            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1238            // moved up to real parser branches. DATABASE / ROLE /
1239            // POLICY / OPERATOR stay no-op forever
1240            // (single-database, hardcoded roles).
1241            Token::Ident(s) | Token::QuotedIdent(s)
1242                if matches!(
1243                    s.to_ascii_lowercase().as_str(),
1244                    "database"
1245                        | "role"
1246                        | "policy"
1247                        | "operator"
1248                        | "cast"
1249                        | "rule"
1250                        | "aggregate"
1251                        | "language"
1252                        | "collation"
1253                        | "conversion"
1254                        // v7.17.0 Phase 8 (audit N6) — rarely-
1255                        // emitted pg_dump shapes that should
1256                        // load through without a parser error.
1257                        // SPG has no planner statistics catalog,
1258                        // no event-trigger hooks, no foreign-
1259                        // data-wrapper infrastructure; consume
1260                        // + return Empty.
1261                        | "statistics"
1262                        | "event"
1263                        | "foreign"
1264                ) =>
1265            {
1266                self.consume_until_statement_boundary();
1267                Ok(Statement::Empty)
1268            }
1269            other => Err(self.err(format!(
1270                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1271            ))),
1272        }
1273    }
1274
1275    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1276    /// keyword decides whether we parse a function or trigger
1277    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1278    /// PROCEDURE) — those land in later releases.
1279    fn parse_create_function_or_trigger_after_or_replace(
1280        &mut self,
1281        or_replace: bool,
1282    ) -> Result<Statement, ParseError> {
1283        let tok = self.peek();
1284        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1285            return Err(self.err(alloc::format!(
1286                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1287            )));
1288        };
1289        if s.eq_ignore_ascii_case("function") {
1290            self.advance();
1291            self.parse_create_function_after_keyword(or_replace)
1292        } else if s.eq_ignore_ascii_case("trigger") {
1293            self.advance();
1294            self.parse_create_trigger_after_keyword(or_replace)
1295        } else if s.eq_ignore_ascii_case("view") {
1296            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1297            self.advance();
1298            self.parse_create_view_after_keyword(or_replace, false, false)
1299        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1300            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1301            self.advance();
1302            let nxt = self.peek().clone();
1303            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1304            {
1305                self.advance();
1306                self.parse_create_view_after_keyword(or_replace, false, true)
1307            } else {
1308                Err(self.err(alloc::format!(
1309                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1310                )))
1311            }
1312        } else {
1313            Err(self.err(alloc::format!(
1314                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1315            )))
1316        }
1317    }
1318
1319    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1320    /// SPG doesn't have a registry; pgvector / similar are
1321    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1322    /// the syntax lets dual-target schemas keep the line.
1323    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1324        // Optional `IF NOT EXISTS`.
1325        self.consume_if_not_exists();
1326        let name = self.expect_ident_like()?;
1327        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1328        // CASCADE / FROM '<v>' clauses; we don't model them.
1329        loop {
1330            match self.peek() {
1331                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1332                    self.advance();
1333                    continue;
1334                }
1335                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1336                    self.advance();
1337                    let _ = self.expect_ident_like()?;
1338                    continue;
1339                }
1340                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1341                    self.advance();
1342                    // String or ident literal.
1343                    let _ = self.advance();
1344                    continue;
1345                }
1346                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1347                    self.advance();
1348                    let _ = self.advance();
1349                    continue;
1350                }
1351                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1352                    self.advance();
1353                    continue;
1354                }
1355                _ => break,
1356            }
1357        }
1358        Ok(Statement::CreateExtension(name))
1359    }
1360
1361    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1362    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1363    /// already been consumed by the caller. Grammar accepted:
1364    ///
1365    ///   name `(` arg-list `)`
1366    ///   `RETURNS` return-type
1367    ///   [ `LANGUAGE` ident ]
1368    ///   `AS` $$ body $$
1369    ///   [ `LANGUAGE` ident ]
1370    ///
1371    /// Either `LANGUAGE` position is allowed; PG accepts both.
1372    fn parse_create_function_after_keyword(
1373        &mut self,
1374        or_replace: bool,
1375    ) -> Result<Statement, ParseError> {
1376        let name = self.expect_ident_like()?;
1377        // Argument list. v7.12.4 commonly sees the empty `()`
1378        // (trigger functions); typed args parse and round-trip
1379        // but the executor only invokes nullary functions.
1380        if !matches!(self.peek(), Token::LParen) {
1381            return Err(self.err(alloc::format!(
1382                "expected '(' after function name {name:?}, got {:?}",
1383                self.peek()
1384            )));
1385        }
1386        self.advance();
1387        let args = self.parse_function_arg_list()?;
1388        // RETURNS clause.
1389        let tok = self.peek();
1390        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1391            return Err(self.err(alloc::format!(
1392                "expected RETURNS after function arg list, got {tok:?}"
1393            )));
1394        };
1395        if !s.eq_ignore_ascii_case("returns") {
1396            return Err(self.err(alloc::format!(
1397                "expected RETURNS after function arg list, got {s:?}"
1398            )));
1399        }
1400        self.advance();
1401        let returns = self.parse_function_return()?;
1402        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1403        // re-check after the body too).
1404        let mut language: Option<String> = self.parse_optional_language()?;
1405        // `AS` followed by a $$-quoted body (lexer already
1406        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1407        // Token::String). AS is a reserved keyword (Token::As).
1408        if !matches!(self.peek(), Token::As) {
1409            return Err(self.err(alloc::format!(
1410                "expected AS before function body, got {:?}",
1411                self.peek()
1412            )));
1413        }
1414        self.advance();
1415        let body_text = match self.peek() {
1416            Token::String(s) => {
1417                let body = s.clone();
1418                self.advance();
1419                body
1420            }
1421            other => {
1422                return Err(self.err(alloc::format!(
1423                    "expected $$-quoted function body after AS, got {other:?}"
1424                )));
1425            }
1426        };
1427        // Trailing optional LANGUAGE clause (the other PG position).
1428        if language.is_none() {
1429            language = self.parse_optional_language()?;
1430        }
1431        let language = language.unwrap_or_else(|| String::from("sql"));
1432        // PL/pgSQL bodies get structure-parsed. Other languages
1433        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1434        // recognise) round-trip as Raw text — the executor errors
1435        // when invoked with a clear unsupported message.
1436        let body = if language.eq_ignore_ascii_case("plpgsql") {
1437            match parse_plpgsql_body(&body_text) {
1438                Ok(block) => FunctionBody::PlPgSql(block),
1439                // Best-effort: if the body parser doesn't yet
1440                // support a construct used inside, fall back to
1441                // raw — keeps `CREATE FUNCTION` itself working
1442                // (catalogue accepts), executor errors on
1443                // invocation only.
1444                Err(_) => FunctionBody::Raw(body_text),
1445            }
1446        } else {
1447            FunctionBody::Raw(body_text)
1448        };
1449        Ok(Statement::CreateFunction(CreateFunctionStatement {
1450            name,
1451            or_replace,
1452            args,
1453            returns,
1454            language,
1455            body,
1456        }))
1457    }
1458
1459    /// Closing `)`-terminated argument list. v7.12.4 commonly
1460    /// sees the empty `()`; typed args round-trip but the
1461    /// executor (yet) doesn't invoke them.
1462    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1463        let mut args: Vec<FunctionArg> = Vec::new();
1464        if matches!(self.peek(), Token::RParen) {
1465            self.advance();
1466            return Ok(args);
1467        }
1468        loop {
1469            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1470            // a reserved token; OUT / INOUT are bare idents.
1471            let mode = if matches!(self.peek(), Token::In) {
1472                self.advance();
1473                FunctionArgMode::In
1474            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1475            {
1476                self.advance();
1477                FunctionArgMode::Out
1478            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1479            {
1480                self.advance();
1481                FunctionArgMode::InOut
1482            } else {
1483                FunctionArgMode::In
1484            };
1485            // Optional name. The next token is either a name
1486            // (followed by a type ident) or the type itself.
1487            // Disambiguate by peeking ahead: if the token after
1488            // the next ident is also an ident, we treat the
1489            // first as the name.
1490            let (name, ty_token) = {
1491                let first = self.expect_ident_like()?;
1492                // Peek next: if it's an ident (i.e. a type
1493                // name) the `first` was the arg name.
1494                match self.peek() {
1495                    Token::Ident(_) | Token::QuotedIdent(_) => {
1496                        let ty = self.expect_ident_like()?;
1497                        (Some(first), ty)
1498                    }
1499                    _ => (None, first),
1500                }
1501            };
1502            // Type — try to map to ColumnTypeName, else Raw.
1503            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1504                Some(t) => FunctionArgType::Typed(t),
1505                None => FunctionArgType::Raw(ty_token),
1506            };
1507            args.push(FunctionArg { mode, name, ty });
1508            match self.peek() {
1509                Token::Comma => {
1510                    self.advance();
1511                    continue;
1512                }
1513                Token::RParen => {
1514                    self.advance();
1515                    return Ok(args);
1516                }
1517                other => {
1518                    return Err(self.err(alloc::format!(
1519                        "expected , or ) in function arg list, got {other:?}"
1520                    )));
1521                }
1522            }
1523        }
1524    }
1525
1526    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1527        let ident = self.expect_ident_like()?;
1528        if ident.eq_ignore_ascii_case("trigger") {
1529            return Ok(FunctionReturn::Trigger);
1530        }
1531        if ident.eq_ignore_ascii_case("void") {
1532            return Ok(FunctionReturn::Void);
1533        }
1534        match map_type_ident_to_column_type_name(&ident) {
1535            Some(t) => Ok(FunctionReturn::Type(t)),
1536            None => Ok(FunctionReturn::Other(ident)),
1537        }
1538    }
1539
1540    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1541        match self.peek() {
1542            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1543                self.advance();
1544                let lang = self.expect_ident_like()?;
1545                Ok(Some(lang.to_ascii_lowercase()))
1546            }
1547            _ => Ok(None),
1548        }
1549    }
1550
1551    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1552    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1553    /// (expr)]*`. The `DOMAIN` keyword has already been
1554    /// consumed. PG allows the trailing constraints in any
1555    /// order; we approximate with a small loop.
1556    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1557        let name = self.expect_ident_like()?;
1558        // Optional `AS`.
1559        if matches!(self.peek(), Token::As) {
1560            self.advance();
1561        }
1562        let base_type = self.parse_column_type_name()?;
1563        let mut default: Option<Expr> = None;
1564        let mut not_null = false;
1565        let mut checks: Vec<Expr> = Vec::new();
1566        loop {
1567            match self.peek() {
1568                Token::Default => {
1569                    if default.is_some() {
1570                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1571                    }
1572                    self.advance();
1573                    default = Some(self.parse_expr(0)?);
1574                }
1575                Token::Not => {
1576                    self.advance();
1577                    if !matches!(self.peek(), Token::Null) {
1578                        return Err(self.err(alloc::format!(
1579                            "expected NULL after NOT in DOMAIN, got {:?}",
1580                            self.peek()
1581                        )));
1582                    }
1583                    self.advance();
1584                    not_null = true;
1585                }
1586                Token::Null => {
1587                    self.advance();
1588                    // NULL after a NOT NULL is contradictory, but
1589                    // PG accepts bare NULL as the default-nullable
1590                    // marker. No-op.
1591                }
1592                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1593                    self.advance();
1594                    if !matches!(self.peek(), Token::LParen) {
1595                        return Err(self.err(alloc::format!(
1596                            "expected '(' after CHECK in DOMAIN, got {:?}",
1597                            self.peek()
1598                        )));
1599                    }
1600                    self.advance();
1601                    let expr = self.parse_expr(0)?;
1602                    if !matches!(self.peek(), Token::RParen) {
1603                        return Err(self.err(alloc::format!(
1604                            "expected ')' after CHECK expr, got {:?}",
1605                            self.peek()
1606                        )));
1607                    }
1608                    self.advance();
1609                    checks.push(expr);
1610                }
1611                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1612                // prefix on the constraint; we drop the name and
1613                // recurse into the constraint parsing.
1614                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1615                    self.advance();
1616                    let _ = self.expect_ident_like()?;
1617                }
1618                _ => break,
1619            }
1620        }
1621        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1622            name,
1623            base_type,
1624            default,
1625            not_null,
1626            checks,
1627        }))
1628    }
1629
1630    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1631    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1632    /// consumed.
1633    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1634        let name = self.expect_ident_like()?;
1635        // Required `AS`.
1636        if !matches!(self.peek(), Token::As) {
1637            return Err(self.err(alloc::format!(
1638                "expected AS after CREATE TYPE {name:?}, got {:?}",
1639                self.peek()
1640            )));
1641        }
1642        self.advance();
1643        // Required `ENUM` ident.
1644        let kind_ident = match self.peek().clone() {
1645            Token::Ident(s) | Token::QuotedIdent(s) => s,
1646            other => {
1647                return Err(self.err(alloc::format!(
1648                    "expected ENUM after CREATE TYPE {name:?} AS, got {other:?}"
1649                )));
1650            }
1651        };
1652        if !kind_ident.eq_ignore_ascii_case("enum") {
1653            return Err(self.err(alloc::format!(
1654                "Phase 1.4 only supports ENUM; got {kind_ident:?}"
1655            )));
1656        }
1657        self.advance();
1658        if !matches!(self.peek(), Token::LParen) {
1659            return Err(self.err(alloc::format!(
1660                "expected '(' after ENUM, got {:?}",
1661                self.peek()
1662            )));
1663        }
1664        self.advance();
1665        let mut labels: Vec<String> = Vec::new();
1666        loop {
1667            match self.peek().clone() {
1668                Token::String(s) => {
1669                    self.advance();
1670                    labels.push(s);
1671                }
1672                other => {
1673                    return Err(
1674                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1675                    );
1676                }
1677            }
1678            if matches!(self.peek(), Token::Comma) {
1679                self.advance();
1680                continue;
1681            }
1682            if matches!(self.peek(), Token::RParen) {
1683                self.advance();
1684                break;
1685            }
1686            return Err(self.err(alloc::format!(
1687                "expected , or ) in ENUM label list, got {:?}",
1688                self.peek()
1689            )));
1690        }
1691        if labels.is_empty() {
1692            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
1693        }
1694        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1695            name,
1696            kind: crate::ast::TypeKind::Enum { labels },
1697        }))
1698    }
1699
1700    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
1701    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
1702    /// The `CREATE MATERIALIZED VIEW` keywords have already been
1703    /// consumed.
1704    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
1705        let if_not_exists = self.parse_if_not_exists();
1706        let name = self.expect_ident_like()?;
1707        let mut columns: Vec<String> = Vec::new();
1708        if matches!(self.peek(), Token::LParen) {
1709            self.advance();
1710            loop {
1711                let c = self.expect_ident_like()?;
1712                columns.push(c);
1713                if matches!(self.peek(), Token::Comma) {
1714                    self.advance();
1715                    continue;
1716                }
1717                if matches!(self.peek(), Token::RParen) {
1718                    self.advance();
1719                    break;
1720                }
1721                return Err(self.err(alloc::format!(
1722                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
1723                    self.peek()
1724                )));
1725            }
1726        }
1727        if !matches!(self.peek(), Token::As) {
1728            return Err(self.err(alloc::format!(
1729                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
1730                self.peek()
1731            )));
1732        }
1733        self.advance();
1734        let body_stmt = self.parse_select_stmt()?;
1735        let Statement::Select(body) = body_stmt else {
1736            return Err(self.err(alloc::format!(
1737                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
1738            )));
1739        };
1740        // Optional trailing `WITH [NO] DATA`.
1741        let with_data = self.parse_optional_with_data(true)?;
1742        Ok(Statement::CreateMaterializedView(
1743            crate::ast::CreateMaterializedViewStatement {
1744                name,
1745                if_not_exists,
1746                columns,
1747                body,
1748                with_data,
1749            },
1750        ))
1751    }
1752
1753    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
1754    /// `default_when_absent` is what to return if the tail is
1755    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
1756    /// WITH DATA).
1757    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
1758        let save = self.pos;
1759        // `WITH` is an Ident (not reserved in the lexer).
1760        let is_with = match self.peek() {
1761            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
1762            _ => false,
1763        };
1764        if !is_with {
1765            return Ok(default_when_absent);
1766        }
1767        self.advance();
1768        // Optional `NO`.
1769        let mut with_data = true;
1770        let is_no = match self.peek() {
1771            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
1772            _ => false,
1773        };
1774        if is_no {
1775            self.advance();
1776            with_data = false;
1777        }
1778        // Required `DATA` ident.
1779        let is_data = match self.peek() {
1780            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
1781            _ => false,
1782        };
1783        if is_data {
1784            self.advance();
1785            Ok(with_data)
1786        } else {
1787            // Caller's WITH wasn't WITH-DATA — rewind so the outer
1788            // parser can interpret it.
1789            self.pos = save;
1790            Ok(default_when_absent)
1791        }
1792    }
1793
1794    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
1795    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
1796    /// All keyword prefixes have already been consumed; the flags
1797    /// say which were present.
1798    fn parse_create_view_after_keyword(
1799        &mut self,
1800        or_replace: bool,
1801        _materialized_unused: bool,
1802        temporary: bool,
1803    ) -> Result<Statement, ParseError> {
1804        let if_not_exists = self.parse_if_not_exists();
1805        let name = self.expect_ident_like()?;
1806        // Optional `(col, col, …)` rename list.
1807        let mut columns: Vec<String> = Vec::new();
1808        if matches!(self.peek(), Token::LParen) {
1809            self.advance();
1810            loop {
1811                let c = self.expect_ident_like()?;
1812                columns.push(c);
1813                if matches!(self.peek(), Token::Comma) {
1814                    self.advance();
1815                    continue;
1816                }
1817                if matches!(self.peek(), Token::RParen) {
1818                    self.advance();
1819                    break;
1820                }
1821                return Err(self.err(alloc::format!(
1822                    "expected , or ) in VIEW column list, got {:?}",
1823                    self.peek()
1824                )));
1825            }
1826        }
1827        // Required `AS`.
1828        if !matches!(self.peek(), Token::As) {
1829            return Err(self.err(alloc::format!(
1830                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
1831                self.peek()
1832            )));
1833        }
1834        self.advance();
1835        // Body: a regular SELECT statement.
1836        let body_stmt = self.parse_select_stmt()?;
1837        let Statement::Select(body) = body_stmt else {
1838            return Err(self.err(alloc::format!(
1839                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
1840            )));
1841        };
1842        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
1843            name,
1844            or_replace,
1845            if_not_exists,
1846            temporary,
1847            columns,
1848            body,
1849        }))
1850    }
1851
1852    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
1853    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
1854    /// consumed; `temporary` carries whether TEMPORARY was seen.
1855    fn parse_create_sequence_after_keyword(
1856        &mut self,
1857        temporary: bool,
1858    ) -> Result<Statement, ParseError> {
1859        let if_not_exists = self.parse_if_not_exists();
1860        let name = self.expect_ident_like()?;
1861        // Optional `AS data_type`.
1862        let data_type = if matches!(self.peek(), Token::As) {
1863            self.advance();
1864            Some(self.parse_sequence_data_type()?)
1865        } else {
1866            None
1867        };
1868        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
1869        Ok(Statement::CreateSequence(
1870            crate::ast::CreateSequenceStatement {
1871                name,
1872                if_not_exists,
1873                temporary,
1874                data_type,
1875                options,
1876            },
1877        ))
1878    }
1879
1880    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
1881    /// already been consumed; this is reached after `SEQUENCE`.
1882    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
1883        let if_exists = self.parse_if_exists();
1884        let name = self.expect_ident_like()?;
1885        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
1886        Ok(Statement::AlterSequence(
1887            crate::ast::AlterSequenceStatement {
1888                name,
1889                if_exists,
1890                options,
1891            },
1892        ))
1893    }
1894
1895    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
1896        let kw = self.expect_ident_like()?;
1897        match kw.to_ascii_lowercase().as_str() {
1898            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
1899            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
1900            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
1901            other => Err(self.err(alloc::format!(
1902                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
1903            ))),
1904        }
1905    }
1906
1907    fn parse_sequence_options(
1908        &mut self,
1909        allow_restart: bool,
1910    ) -> Result<crate::ast::SequenceOptions, ParseError> {
1911        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
1912        let mut opts = SequenceOptions::default();
1913        #[allow(clippy::while_let_loop)]
1914        loop {
1915            // Match an ident; stop at any non-ident token (sentinel,
1916            // semicolon, end of statement).
1917            let kw_lc = match self.peek() {
1918                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
1919                _ => break,
1920            };
1921            match kw_lc.as_str() {
1922                "increment" => {
1923                    self.advance();
1924                    // Optional BY.
1925                    if matches!(self.peek(), Token::By) {
1926                        self.advance();
1927                    }
1928                    opts.increment = Some(self.expect_signed_int()?);
1929                }
1930                "minvalue" => {
1931                    self.advance();
1932                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
1933                }
1934                "maxvalue" => {
1935                    self.advance();
1936                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
1937                }
1938                "no" => {
1939                    self.advance();
1940                    let what = self.expect_ident_like()?;
1941                    match what.to_ascii_lowercase().as_str() {
1942                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
1943                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
1944                        "cycle" => opts.cycle = Some(false),
1945                        other => {
1946                            return Err(self.err(alloc::format!(
1947                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
1948                            )));
1949                        }
1950                    }
1951                }
1952                "start" => {
1953                    self.advance();
1954                    // Optional WITH.
1955                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1956                        if s.eq_ignore_ascii_case("with"))
1957                    {
1958                        self.advance();
1959                    }
1960                    opts.start = Some(self.expect_signed_int()?);
1961                }
1962                "restart" if allow_restart => {
1963                    self.advance();
1964                    // Optional WITH n; bare RESTART means restart at START.
1965                    let mut with_val: Option<i64> = None;
1966                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1967                        if s.eq_ignore_ascii_case("with"))
1968                    {
1969                        self.advance();
1970                        with_val = Some(self.expect_signed_int()?);
1971                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
1972                        with_val = Some(self.expect_signed_int()?);
1973                    }
1974                    opts.restart = Some(with_val);
1975                }
1976                "cache" => {
1977                    self.advance();
1978                    opts.cache = Some(self.expect_signed_int()?);
1979                }
1980                "cycle" => {
1981                    self.advance();
1982                    opts.cycle = Some(true);
1983                }
1984                "owned" => {
1985                    self.advance();
1986                    // BY is a reserved Token::By; accept either form.
1987                    match self.peek() {
1988                        Token::By => {
1989                            self.advance();
1990                        }
1991                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
1992                            self.advance();
1993                        }
1994                        other => {
1995                            return Err(
1996                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
1997                            );
1998                        }
1999                    }
2000                    // OWNED BY {NONE | tab.col}. Read just one ident
2001                    // (NOT expect_ident_like which would auto-strip
2002                    // a schema prefix and consume the `.col` we need).
2003                    let first = match self.advance() {
2004                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2005                        other => {
2006                            return Err(self.err(alloc::format!(
2007                                "expected identifier or NONE after OWNED BY, got {other:?}"
2008                            )));
2009                        }
2010                    };
2011                    if first.eq_ignore_ascii_case("none") {
2012                        opts.owned_by = Some(SequenceOwnedBy::None);
2013                    } else if matches!(self.peek(), Token::Dot) {
2014                        self.advance();
2015                        let second = match self.advance() {
2016                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2017                            other => {
2018                                return Err(self.err(alloc::format!(
2019                                    "expected column name after OWNED BY {first}., got {other:?}"
2020                                )));
2021                            }
2022                        };
2023                        // v7.17 dump-compat fix — pg_dump emits
2024                        // OWNED BY clauses as
2025                        // `schema.table.column` (three segments).
2026                        // If a third `.<ident>` follows, treat the
2027                        // first ident as schema (drop it; SPG is
2028                        // single-schema) and the middle / last
2029                        // pair as table.column. Otherwise it's
2030                        // the two-segment form table.column.
2031                        if matches!(self.peek(), Token::Dot) {
2032                            self.advance();
2033                            let third = match self.advance() {
2034                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2035                                other => {
2036                                    return Err(self.err(alloc::format!(
2037                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2038                                    )));
2039                                }
2040                            };
2041                            let _ = first; // schema prefix discarded
2042                            opts.owned_by = Some(SequenceOwnedBy::Column {
2043                                table: second,
2044                                column: third,
2045                            });
2046                        } else {
2047                            opts.owned_by = Some(SequenceOwnedBy::Column {
2048                                table: first,
2049                                column: second,
2050                            });
2051                        }
2052                    } else {
2053                        return Err(self.err(alloc::format!(
2054                            "expected table.column or NONE after OWNED BY, got {first:?}"
2055                        )));
2056                    }
2057                }
2058                _ => break,
2059            }
2060        }
2061        Ok(opts)
2062    }
2063
2064    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2065        let neg = if matches!(self.peek(), Token::Minus) {
2066            self.advance();
2067            true
2068        } else {
2069            false
2070        };
2071        match self.peek() {
2072            Token::Integer(n) => {
2073                let v = *n;
2074                self.advance();
2075                Ok(if neg { -v } else { v })
2076            }
2077            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2078        }
2079    }
2080
2081    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2082    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2083    /// clause is fully accepted and discarded — SPG always runs
2084    /// constraint checks immediately (single-writer model). The
2085    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2086    /// in either order (per the SQL spec they're independent),
2087    /// though pg_dump always emits them in the canonical
2088    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2089    /// Stops at the first token that isn't part of the clause.
2090    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2091        loop {
2092            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2093            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2094                self.advance();
2095                self.consume_optional_initially_clause()?;
2096                continue;
2097            }
2098            // `NOT DEFERRABLE` — already worked pre-3.1.
2099            if matches!(self.peek(), Token::Not) {
2100                let look = self.tokens.get(self.pos + 1);
2101                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2102                    self.advance(); // NOT
2103                    self.advance(); // DEFERRABLE
2104                    self.consume_optional_initially_clause()?;
2105                    continue;
2106                }
2107                break;
2108            }
2109            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2110            // accepts this without a leading [NOT] DEFERRABLE
2111            // (the timing keyword alone). pg_dump occasionally
2112            // emits it on FK constraints that inherit timing.
2113            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2114                self.consume_optional_initially_clause()?;
2115                continue;
2116            }
2117            break;
2118        }
2119        Ok(())
2120    }
2121
2122    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2123    /// next token is `INITIALLY`, consume it plus the required
2124    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2125    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2126        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2127            return Ok(());
2128        }
2129        self.advance(); // INITIALLY
2130        match self.advance() {
2131            Token::Ident(s)
2132                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2133            {
2134                Ok(())
2135            }
2136            other => Err(self.err(alloc::format!(
2137                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2138            ))),
2139        }
2140    }
2141
2142    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2143    /// in its entirety so the parser returns Empty without
2144    /// touching the runtime. The CREATE+PROCEDURE keywords are
2145    /// already consumed; this swallows everything from the
2146    /// procedure name through the matching `END`, including
2147    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2148    /// (DELIMITER `//` makes the script splitter forward the
2149    /// whole block as one statement), `@var` session-variable
2150    /// references, and the trailing terminator.
2151    ///
2152    /// Tracks nesting depth so:
2153    ///   BEGIN
2154    ///     IF cond THEN
2155    ///       BEGIN ... END;
2156    ///     END IF;
2157    ///   END
2158    /// terminates at the outer END.
2159    fn consume_mysql_routine_body(&mut self) {
2160        // Outer skeleton: name, (...), optional clauses, BEGIN
2161        // <body> END [;]. Scan for the first BEGIN — anything
2162        // before it is signature decoration we don't care about.
2163        // Once inside BEGIN, count up on BEGIN, down on END.
2164        let mut depth: i32 = 0;
2165        let mut started = false;
2166        loop {
2167            match self.peek().clone() {
2168                Token::Begin => {
2169                    self.advance();
2170                    depth += 1;
2171                    started = true;
2172                }
2173                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2174                    self.advance();
2175                    if started {
2176                        depth -= 1;
2177                        if depth <= 0 {
2178                            // Optional trailing ident (`END IF`,
2179                            // `END LOOP`, `END WHILE`, `END CASE`,
2180                            // `END label_name`) — eat the next
2181                            // ident if present so we don't
2182                            // mistake `END IF;` for the outer
2183                            // close.
2184                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2185                                // If the next token is one of the
2186                                // PL/SQL block-closer keywords,
2187                                // the END belongs to an inner
2188                                // block; bump depth back up.
2189                                let is_inner_close = matches!(
2190                                    self.peek(),
2191                                    Token::Ident(s) | Token::QuotedIdent(s)
2192                                        if matches!(
2193                                            s.to_ascii_lowercase().as_str(),
2194                                            "if" | "loop" | "while" | "case" | "repeat"
2195                                        )
2196                                );
2197                                if is_inner_close {
2198                                    self.advance();
2199                                    depth += 1;
2200                                    continue;
2201                                }
2202                            }
2203                            // Eat optional trailing `;`.
2204                            if matches!(self.peek(), Token::Semicolon) {
2205                                self.advance();
2206                            }
2207                            return;
2208                        }
2209                    }
2210                }
2211                Token::Eof => return,
2212                _ => {
2213                    self.advance();
2214                }
2215            }
2216        }
2217    }
2218
2219    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2220    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2221    ///
2222    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2223    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2224    ///   ident, or `ident @ ident-or-quoted-string` host form)
2225    /// * `SQL SECURITY {DEFINER|INVOKER}`
2226    ///
2227    /// Each clause may appear at most once but in any order.
2228    /// The hints are pure planner / permission metadata that
2229    /// SPG's view-rewrite engine handles uniformly; we accept
2230    /// and discard. Returns `Ok(())` once a non-clause token is
2231    /// peeked (the caller then checks for the `VIEW` keyword).
2232    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2233        loop {
2234            match self.peek().clone() {
2235                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2236                    self.advance(); // ALGORITHM
2237                    // Optional `=`. MySQL spec requires it but be
2238                    // generous.
2239                    if matches!(self.peek(), Token::Eq) {
2240                        self.advance();
2241                    }
2242                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2243                    // bare ident; unknown values still parse so
2244                    // future MySQL versions don't break.
2245                    if matches!(
2246                        self.peek(),
2247                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2248                    ) {
2249                        self.advance();
2250                    }
2251                }
2252                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2253                    self.advance(); // DEFINER
2254                    if matches!(self.peek(), Token::Eq) {
2255                        self.advance();
2256                    }
2257                    // User: quoted string, ident, OR ident @ host
2258                    // (host may itself be quoted or bare).
2259                    match self.peek().clone() {
2260                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2261                            self.advance();
2262                            // Optional `@host`.
2263                            if matches!(self.peek(), Token::At) {
2264                                self.advance();
2265                                if matches!(
2266                                    self.peek(),
2267                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2268                                ) {
2269                                    self.advance();
2270                                }
2271                            }
2272                        }
2273                        _ => {}
2274                    }
2275                }
2276                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2277                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2278                    // when followed by SECURITY — the dispatcher must
2279                    // not consume a bare `SQL` token (it's not a
2280                    // legal CREATE prefix on its own).
2281                    let save = self.pos;
2282                    self.advance(); // SQL
2283                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2284                        if s2.eq_ignore_ascii_case("security"))
2285                    {
2286                        self.advance(); // SECURITY
2287                        // DEFINER / INVOKER trailing ident.
2288                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2289                            self.advance();
2290                        }
2291                    } else {
2292                        // Not a SQL SECURITY clause — roll back and
2293                        // bail; the caller will error out cleanly.
2294                        self.pos = save;
2295                        return Ok(());
2296                    }
2297                }
2298                _ => return Ok(()),
2299            }
2300        }
2301    }
2302
2303    fn parse_if_not_exists(&mut self) -> bool {
2304        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2305        {
2306            let save = self.pos;
2307            self.advance();
2308            if matches!(self.peek(), Token::Not) {
2309                self.advance();
2310                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2311                {
2312                    self.advance();
2313                    return true;
2314                }
2315            }
2316            self.pos = save;
2317        }
2318        false
2319    }
2320
2321    fn parse_if_exists(&mut self) -> bool {
2322        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2323        {
2324            let save = self.pos;
2325            self.advance();
2326            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2327            {
2328                self.advance();
2329                return true;
2330            }
2331            self.pos = save;
2332        }
2333        false
2334    }
2335
2336    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2337    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2338    /// been consumed.
2339    fn parse_create_trigger_after_keyword(
2340        &mut self,
2341        or_replace: bool,
2342    ) -> Result<Statement, ParseError> {
2343        let name = self.expect_ident_like()?;
2344        let timing = {
2345            let ident = self.expect_ident_like()?;
2346            if ident.eq_ignore_ascii_case("before") {
2347                TriggerTiming::Before
2348            } else if ident.eq_ignore_ascii_case("after") {
2349                TriggerTiming::After
2350            } else if ident.eq_ignore_ascii_case("instead") {
2351                let next = self.expect_ident_like()?;
2352                if !next.eq_ignore_ascii_case("of") {
2353                    return Err(self.err(alloc::format!(
2354                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2355                    )));
2356                }
2357                TriggerTiming::InsteadOf
2358            } else {
2359                return Err(self.err(alloc::format!(
2360                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2361                )));
2362            }
2363        };
2364        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2365        // OR is a reserved keyword token (Token::Or), not an Ident.
2366        // v7.13.0 — after an UPDATE event we may optionally see
2367        // `OF col, col, …` (mailrs round-5 G7). Columns are
2368        // captured into `update_columns` once across the whole
2369        // events list; multiple `UPDATE OF` clauses are rejected.
2370        let mut events: Vec<TriggerEvent> = Vec::new();
2371        let mut update_columns: Vec<String> = Vec::new();
2372        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2373        events.push(first_ev);
2374        if !first_cols.is_empty() {
2375            update_columns = first_cols;
2376        }
2377        while matches!(self.peek(), Token::Or) {
2378            self.advance();
2379            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2380            events.push(ev);
2381            if !cols.is_empty() {
2382                if !update_columns.is_empty() {
2383                    return Err(
2384                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2385                    );
2386                }
2387                update_columns = cols;
2388            }
2389        }
2390        // ON <table>
2391        let tok = self.peek();
2392        let Token::On = tok else {
2393            return Err(self.err(alloc::format!(
2394                "expected ON after trigger events, got {tok:?}"
2395            )));
2396        };
2397        self.advance();
2398        let table = self.expect_ident_like()?;
2399        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2400        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2401        // idents.
2402        if !matches!(self.peek(), Token::For) {
2403            return Err(self.err(alloc::format!(
2404                "expected FOR EACH ROW / STATEMENT, got {:?}",
2405                self.peek()
2406            )));
2407        }
2408        self.advance();
2409        let for_each = {
2410            let e = self.expect_ident_like()?;
2411            if !e.eq_ignore_ascii_case("each") {
2412                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2413            }
2414            let unit = self.expect_ident_like()?;
2415            if unit.eq_ignore_ascii_case("row") {
2416                TriggerForEach::Row
2417            } else if unit.eq_ignore_ascii_case("statement") {
2418                TriggerForEach::Statement
2419            } else {
2420                return Err(self.err(alloc::format!(
2421                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2422                )));
2423            }
2424        };
2425        // EXECUTE FUNCTION/PROCEDURE name(...)
2426        let exec = self.expect_ident_like()?;
2427        if !exec.eq_ignore_ascii_case("execute") {
2428            return Err(self.err(alloc::format!(
2429                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2430            )));
2431        }
2432        let fn_or_proc = self.expect_ident_like()?;
2433        if !(fn_or_proc.eq_ignore_ascii_case("function")
2434            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2435        {
2436            return Err(self.err(alloc::format!(
2437                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2438            )));
2439        }
2440        let function = self.expect_ident_like()?;
2441        // Optional empty arg list `()`.
2442        if matches!(self.peek(), Token::LParen) {
2443            self.advance();
2444            if !matches!(self.peek(), Token::RParen) {
2445                return Err(self.err(alloc::format!(
2446                    "v7.12.4 trigger function calls take no args; got {:?}",
2447                    self.peek()
2448                )));
2449            }
2450            self.advance();
2451        }
2452        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2453            name,
2454            or_replace,
2455            timing,
2456            events,
2457            table,
2458            for_each,
2459            function,
2460            update_columns,
2461        }))
2462    }
2463
2464    /// v7.13.0 — parse one trigger event, then optionally consume
2465    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2466    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2467    fn parse_trigger_event_with_optional_of(
2468        &mut self,
2469    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2470        let ev = self.parse_trigger_event()?;
2471        if !matches!(ev, TriggerEvent::Update) {
2472            return Ok((ev, Vec::new()));
2473        }
2474        // `OF` is a bare ident.
2475        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2476            return Ok((ev, Vec::new()));
2477        }
2478        self.advance(); // OF
2479        let mut cols: Vec<String> = Vec::new();
2480        loop {
2481            cols.push(self.expect_ident_like()?);
2482            if matches!(self.peek(), Token::Comma) {
2483                self.advance();
2484                continue;
2485            }
2486            break;
2487        }
2488        if cols.is_empty() {
2489            return Err(
2490                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2491            );
2492        }
2493        Ok((ev, cols))
2494    }
2495
2496    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2497    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2498    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2499    /// inside the body.
2500    /// Called by [`parse_plpgsql_body`] after the body's tokens
2501    /// have been lexed into this temporary parser.
2502    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2503        // v7.12.6 — optional DECLARE prelude.
2504        let declarations = if matches!(
2505            self.peek(),
2506            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2507        ) {
2508            self.advance();
2509            self.parse_plpgsql_declare_block()?
2510        } else {
2511            Vec::new()
2512        };
2513        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2514        // `BEGIN` transaction-start, but we can reuse the
2515        // reserved Token::Begin since the body is a separate
2516        // lex/parse context).
2517        if !matches!(self.peek(), Token::Begin) {
2518            return Err(self.err(alloc::format!(
2519                "expected BEGIN at start of plpgsql block, got {:?}",
2520                self.peek()
2521            )));
2522        }
2523        self.advance();
2524        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2525        Ok(PlPgSqlBlock {
2526            declarations,
2527            statements,
2528        })
2529    }
2530
2531    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2532    /// prelude. Caller has already consumed `DECLARE`. We stop
2533    /// reading entries when we hit `BEGIN`.
2534    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2535        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2536        loop {
2537            if matches!(self.peek(), Token::Begin) {
2538                return Ok(out);
2539            }
2540            let name = self.expect_ident_like()?;
2541            let ty_token = self.expect_ident_like()?;
2542            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2543                Some(t) => FunctionArgType::Typed(t),
2544                None => FunctionArgType::Raw(ty_token),
2545            };
2546            let default = match self.peek() {
2547                Token::ColonEq => {
2548                    self.advance();
2549                    Some(self.parse_expr(0)?)
2550                }
2551                Token::Eq => {
2552                    // PL/pgSQL also accepts `=` for the
2553                    // DECLARE default (PG treats them the same
2554                    // in this position).
2555                    self.advance();
2556                    Some(self.parse_expr(0)?)
2557                }
2558                _ => None,
2559            };
2560            // Mandatory `;` between declarations.
2561            if !matches!(self.peek(), Token::Semicolon) {
2562                return Err(self.err(alloc::format!(
2563                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2564                    self.peek()
2565                )));
2566            }
2567            self.advance();
2568            out.push(PlPgSqlDeclare { name, ty, default });
2569        }
2570    }
2571
2572    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2573    /// the terminating `END;` (or `END IF;` etc — handled by the
2574    /// per-construct sub-parsers). Used by both the outer block
2575    /// and the IF/ELSE branch bodies.
2576    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2577        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2578        loop {
2579            // Allow trailing semicolons + END.
2580            while matches!(self.peek(), Token::Semicolon) {
2581                self.advance();
2582            }
2583            // END / ELSE / ELSIF — handled by the caller.
2584            if matches!(
2585                self.peek(),
2586                Token::Ident(s) | Token::QuotedIdent(s)
2587                    if s.eq_ignore_ascii_case("end")
2588                        || s.eq_ignore_ascii_case("else")
2589                        || s.eq_ignore_ascii_case("elsif")
2590                        || s.eq_ignore_ascii_case("elseif")
2591            ) {
2592                return Ok(statements);
2593            }
2594            // Otherwise: one statement, then expect `;` or
2595            // a block-terminator keyword.
2596            let stmt = self.parse_plpgsql_stmt()?;
2597            statements.push(stmt);
2598            match self.peek() {
2599                Token::Semicolon => {
2600                    self.advance();
2601                }
2602                Token::Ident(s) | Token::QuotedIdent(s)
2603                    if s.eq_ignore_ascii_case("end")
2604                        || s.eq_ignore_ascii_case("else")
2605                        || s.eq_ignore_ascii_case("elsif")
2606                        || s.eq_ignore_ascii_case("elseif") =>
2607                {
2608                    // Final statement of the block without `;`.
2609                }
2610                other => {
2611                    return Err(self.err(alloc::format!(
2612                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2613                    )));
2614                }
2615            }
2616        }
2617    }
2618
2619    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2620        // RETURN keyword?
2621        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2622        {
2623            self.advance();
2624            return self.parse_plpgsql_return();
2625        }
2626        // v7.12.6 — IF block.
2627        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2628        {
2629            self.advance();
2630            return self.parse_plpgsql_if();
2631        }
2632        // v7.12.6 — RAISE.
2633        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2634        {
2635            self.advance();
2636            return self.parse_plpgsql_raise();
2637        }
2638        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2639        // plpgsql-specific shape (mailrs round-10 migrate-042).
2640        // PG's SELECT INTO at top-level SQL would CREATE a new
2641        // table; inside plpgsql it ASSIGNS the query result to
2642        // a local variable. We detect the INTO at paren-depth
2643        // 0 between SELECT and the statement boundary; if
2644        // found, split the token stream into "pre-INTO
2645        // projection" + "var" + "post-INTO FROM/WHERE…" and
2646        // rebuild as a SelectInto with a regular SELECT body
2647        // (no INTO clause).
2648        if matches!(self.peek(), Token::Select)
2649            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2650        {
2651            return Ok(PlPgSqlStmt::SelectInto {
2652                var: var_name,
2653                body: Box::new(select_body),
2654            });
2655        }
2656        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2657        // SELECT can appear directly inside a trigger body; we
2658        // recurse into the regular Statement parser, which will
2659        // stop at the trailing `;` (which our caller then
2660        // consumes).
2661        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2662        // also embed ALTER / CREATE / DROP statements; route
2663        // those through the same parser so the DO body parses
2664        // cleanly.
2665        if matches!(self.peek(), Token::Insert)
2666            || matches!(self.peek(), Token::Select)
2667            || matches!(self.peek(), Token::Create)
2668            || matches!(self.peek(), Token::Drop)
2669            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2670                if s.eq_ignore_ascii_case("update")
2671                    || s.eq_ignore_ascii_case("delete")
2672                    || s.eq_ignore_ascii_case("alter"))
2673        {
2674            let stmt = self.parse_one_statement()?;
2675            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2676        }
2677        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
2678        // followed by `:=` and an expression.
2679        let target = self.parse_plpgsql_assign_target()?;
2680        // PL/pgSQL assignment uses `:=`. The lexer represents
2681        // this as a colon followed by `=`; check both shapes.
2682        match self.peek() {
2683            Token::ColonEq => {
2684                self.advance();
2685            }
2686            Token::Colon => {
2687                self.advance();
2688                if !matches!(self.peek(), Token::Eq) {
2689                    return Err(self.err(alloc::format!(
2690                        "expected := after plpgsql assign target, got `:` then {:?}",
2691                        self.peek()
2692                    )));
2693                }
2694                self.advance();
2695            }
2696            other => {
2697                return Err(self.err(alloc::format!(
2698                    "expected := after plpgsql assign target, got {other:?}"
2699                )));
2700            }
2701        }
2702        let value = self.parse_expr(0)?;
2703        Ok(PlPgSqlStmt::Assign { target, value })
2704    }
2705
2706    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
2707    /// [ELSE body] END IF`. `IF` keyword already consumed.
2708    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2709        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
2710        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
2711        loop {
2712            // <expr> THEN
2713            let cond = self.parse_expr(0)?;
2714            let then_kw = self.expect_ident_like()?;
2715            if !then_kw.eq_ignore_ascii_case("then") {
2716                return Err(self.err(alloc::format!(
2717                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
2718                )));
2719            }
2720            let body = self.parse_plpgsql_stmt_list_until_end()?;
2721            branches.push((cond, body));
2722            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
2723            match self.peek() {
2724                Token::Ident(s) | Token::QuotedIdent(s)
2725                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
2726                {
2727                    self.advance();
2728                    continue;
2729                }
2730                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
2731                    self.advance();
2732                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
2733                    break;
2734                }
2735                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2736                    break;
2737                }
2738                other => {
2739                    return Err(self.err(alloc::format!(
2740                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
2741                    )));
2742                }
2743            }
2744        }
2745        // Expect `END IF` (the END keyword is the one we're
2746        // looking at right now).
2747        let end_kw = self.expect_ident_like()?;
2748        if !end_kw.eq_ignore_ascii_case("end") {
2749            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
2750        }
2751        let if_kw = self.expect_ident_like()?;
2752        if !if_kw.eq_ignore_ascii_case("if") {
2753            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
2754        }
2755        Ok(PlPgSqlStmt::If {
2756            branches,
2757            else_branch,
2758        })
2759    }
2760
2761    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
2762    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
2763    /// is already consumed.
2764    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2765        let lvl_ident = self.expect_ident_like()?;
2766        let level = match lvl_ident.to_ascii_lowercase().as_str() {
2767            "notice" => RaiseLevel::Notice,
2768            "warning" => RaiseLevel::Warning,
2769            "info" => RaiseLevel::Info,
2770            "log" => RaiseLevel::Log,
2771            "debug" => RaiseLevel::Debug,
2772            "exception" => RaiseLevel::Exception,
2773            other => {
2774                return Err(self.err(alloc::format!(
2775                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
2776                )));
2777            }
2778        };
2779        // Message: required for v7.12.6. PG accepts a bare
2780        // RAISE-rethrow form (no message), reserved for future
2781        // RAISE-no-args support.
2782        let Token::String(msg) = self.peek() else {
2783            return Err(self.err(alloc::format!(
2784                "expected RAISE message string, got {:?}",
2785                self.peek()
2786            )));
2787        };
2788        let message = msg.clone();
2789        self.advance();
2790        // Optional comma-separated args (PG `%` format substitution).
2791        let mut args: Vec<Expr> = Vec::new();
2792        while matches!(self.peek(), Token::Comma) {
2793            self.advance();
2794            args.push(self.parse_expr(0)?);
2795        }
2796        Ok(PlPgSqlStmt::Raise {
2797            level,
2798            message,
2799            args,
2800        })
2801    }
2802
2803    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
2804    /// <projection> INTO <var> [FROM …]` (mailrs round-10
2805    /// migrate-042). Returns `(rebuilt_select_without_into,
2806    /// var_name)` when the pattern matches; `None` for
2807    /// regular SELECTs (those go through the embedded-SQL
2808    /// path). Token-stream surgery so the rebuilt SELECT
2809    /// parses through the regular `parse_select_stmt`.
2810    #[allow(clippy::too_many_lines)]
2811    fn try_parse_plpgsql_select_into(
2812        &mut self,
2813    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
2814        // Scan forward from `self.pos + 1` (past Token::Select)
2815        // for Token::Into at paren-depth 0, stopping at the
2816        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
2817        // end the plpgsql statement.
2818        let start = self.pos;
2819        let mut into_pos: Option<usize> = None;
2820        let mut depth: i32 = 0;
2821        let mut i = start + 1;
2822        while i < self.tokens.len() {
2823            match &self.tokens[i] {
2824                Token::LParen => depth += 1,
2825                Token::RParen => depth -= 1,
2826                Token::Semicolon if depth == 0 => break,
2827                Token::Ident(s)
2828                    if depth == 0
2829                        && (s.eq_ignore_ascii_case("end")
2830                            || s.eq_ignore_ascii_case("else")
2831                            || s.eq_ignore_ascii_case("elsif")) =>
2832                {
2833                    break;
2834                }
2835                Token::Into if depth == 0 => {
2836                    into_pos = Some(i);
2837                    break;
2838                }
2839                _ => {}
2840            }
2841            i += 1;
2842        }
2843        let Some(into_at) = into_pos else {
2844            return Ok(None);
2845        };
2846        // The token immediately after INTO must be the target
2847        // var ident; anything else (e.g. INSERT INTO table)
2848        // ruled out by the depth-0 check above. Capture it.
2849        let var = match self.tokens.get(into_at + 1) {
2850            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
2851            other => {
2852                return Err(self.err(alloc::format!(
2853                    "expected variable name after SELECT … INTO, got {other:?}"
2854                )));
2855            }
2856        };
2857        // Find the end of the plpgsql SELECT INTO statement —
2858        // same boundary rules as the depth-0 scan above.
2859        let mut end = into_at + 2;
2860        let mut depth2: i32 = 0;
2861        while end < self.tokens.len() {
2862            match &self.tokens[end] {
2863                Token::LParen => depth2 += 1,
2864                Token::RParen => depth2 -= 1,
2865                Token::Semicolon if depth2 == 0 => break,
2866                Token::Ident(s)
2867                    if depth2 == 0
2868                        && (s.eq_ignore_ascii_case("end")
2869                            || s.eq_ignore_ascii_case("else")
2870                            || s.eq_ignore_ascii_case("elsif")) =>
2871                {
2872                    break;
2873                }
2874                _ => {}
2875            }
2876            end += 1;
2877        }
2878        // Rebuild a token stream that represents the SELECT
2879        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
2880        // post-var tokens up to statement end]. Run the
2881        // regular `parse_select_stmt` against it.
2882        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
2883        for j in start..into_at {
2884            rebuilt.push(self.tokens[j].clone());
2885        }
2886        for j in (into_at + 2)..end {
2887            rebuilt.push(self.tokens[j].clone());
2888        }
2889        rebuilt.push(Token::Eof);
2890        let saved_pos = self.pos;
2891        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
2892        self.pos = 0;
2893        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
2894        if !matches!(self.peek(), Token::Select) {
2895            self.tokens = saved_tokens;
2896            self.pos = saved_pos;
2897            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
2898        }
2899        let sel = self.parse_select_stmt();
2900        self.tokens = saved_tokens;
2901        self.pos = end;
2902        let sel = sel?;
2903        let Statement::Select(body) = sel else {
2904            return Err(self.err(alloc::format!(
2905                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
2906            )));
2907        };
2908        Ok(Some((body, var)))
2909    }
2910
2911    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
2912        // v7.16.1 — read the head token DIRECTLY rather than
2913        // via `expect_ident_like`. The v7.14.0 schema-qualifier
2914        // strip (`public.t` → `t`) inside `expect_ident_like`
2915        // greedily consumes any `ident . ident` pair, which
2916        // silently turned every `NEW.col := …` /
2917        // `OLD.col := …` plpgsql assignment into a Local("col")
2918        // assignment — the head "new"/"old" was eaten as if it
2919        // were a schema name and the Dot was consumed too, so
2920        // this function's own `peek() == Token::Dot` check
2921        // below never fired. Every BEFORE trigger that rewrote
2922        // a NEW cell was a silent no-op for two major releases
2923        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
2924        // gate failures were investigated as v7.16.1 backlog.
2925        let head = match self.advance() {
2926            Token::Ident(s) | Token::QuotedIdent(s) => s,
2927            other => {
2928                return Err(self.err(alloc::format!(
2929                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
2930                )));
2931            }
2932        };
2933        if matches!(self.peek(), Token::Dot) {
2934            self.advance();
2935            let col = self.expect_ident_like()?;
2936            if head.eq_ignore_ascii_case("new") {
2937                return Ok(AssignTarget::NewColumn(col));
2938            }
2939            if head.eq_ignore_ascii_case("old") {
2940                return Ok(AssignTarget::OldColumn(col));
2941            }
2942            return Err(self.err(alloc::format!(
2943                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
2944                 got {head:?}.<col>"
2945            )));
2946        }
2947        Ok(AssignTarget::Local(head))
2948    }
2949
2950    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2951        // RETURN NEW / OLD / NULL — bare-ident forms.
2952        match self.peek() {
2953            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
2954                self.advance();
2955                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
2956            }
2957            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
2958                self.advance();
2959                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
2960            }
2961            Token::Null => {
2962                self.advance();
2963                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
2964            }
2965            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
2966            // per PL/pgSQL convention.
2967            Token::Semicolon => {
2968                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
2969            }
2970            _ => {}
2971        }
2972        // Fall through: parse a full expression.
2973        let e = self.parse_expr(0)?;
2974        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
2975    }
2976
2977    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
2978        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
2979        // are ident-shaped (the parser keys off case-insensitive
2980        // match — same shape used by the top-level Update / Delete
2981        // dispatchers at parse_one_statement).
2982        if matches!(self.peek(), Token::Insert) {
2983            self.advance();
2984            return Ok(TriggerEvent::Insert);
2985        }
2986        match self.peek() {
2987            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
2988                self.advance();
2989                Ok(TriggerEvent::Update)
2990            }
2991            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
2992                self.advance();
2993                Ok(TriggerEvent::Delete)
2994            }
2995            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
2996                self.advance();
2997                Ok(TriggerEvent::Truncate)
2998            }
2999            other => Err(self.err(alloc::format!(
3000                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3001            ))),
3002        }
3003    }
3004
3005    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3006    ///   - (no clause) → implicit `FOR ALL TABLES`
3007    ///   - `FOR ALL TABLES`
3008    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3009    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3010    ///     accepted (PG accepts both forms in PG 19).
3011    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3012        let name = self.expect_ident_or_string()?;
3013        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3014        // shape so existing publications keep parsing identically.
3015        let scope = if matches!(self.peek(), Token::For) {
3016            self.advance();
3017            if matches!(self.peek(), Token::All) {
3018                self.advance();
3019                if !matches!(self.peek(), Token::Tables) {
3020                    return Err(self.err(format!(
3021                        "expected TABLES after FOR ALL, got {:?}",
3022                        self.peek()
3023                    )));
3024                }
3025                self.advance();
3026                if matches!(self.peek(), Token::Except) {
3027                    self.advance();
3028                    let tables = self.parse_publication_table_list()?;
3029                    PublicationScope::AllTablesExcept(tables)
3030                } else {
3031                    PublicationScope::AllTables
3032                }
3033            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3034                // PG 19 accepts both `FOR TABLE …` (singular) and
3035                // `FOR TABLES …` (plural); SPG matches.
3036                self.advance();
3037                let tables = self.parse_publication_table_list()?;
3038                PublicationScope::ForTables(tables)
3039            } else {
3040                return Err(self.err(format!(
3041                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3042                    self.peek()
3043                )));
3044            }
3045        } else {
3046            PublicationScope::AllTables
3047        };
3048        Ok(Statement::CreatePublication(CreatePublicationStatement {
3049            name,
3050            scope,
3051        }))
3052    }
3053
3054    /// v6.1.3 — Comma-separated identifier list for the publication
3055    /// FOR-clause. Requires at least one entry; empty list is a
3056    /// parse error (PG behaviour). Quoted idents are accepted; the
3057    /// names round-trip through `Display` as `quote_ident(name)`.
3058    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3059        let first = self.expect_ident_like()?;
3060        let mut out = alloc::vec![first];
3061        while matches!(self.peek(), Token::Comma) {
3062            self.advance();
3063            out.push(self.expect_ident_like()?);
3064        }
3065        Ok(out)
3066    }
3067
3068    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3069    ///                 CONNECTION '<conn>'
3070    ///                 PUBLICATION <pub> [, <pub> ...]`.
3071    ///
3072    /// The clause order is fixed (CONNECTION first, then
3073    /// PUBLICATION) to match PG. No WITH-options accepted in
3074    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3075    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3076        let name = self.expect_ident_or_string()?;
3077        if !matches!(self.peek(), Token::Connection) {
3078            return Err(self.err(format!(
3079                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3080                self.peek()
3081            )));
3082        }
3083        self.advance();
3084        let conn_str = self.expect_string_literal()?;
3085        if !matches!(self.peek(), Token::Publication) {
3086            return Err(self.err(format!(
3087                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3088                self.peek()
3089            )));
3090        }
3091        self.advance();
3092        // Reuse the publication FOR-list parser shape: at least one
3093        // identifier, comma-separated.
3094        let first = self.expect_ident_like()?;
3095        let mut publications = alloc::vec![first];
3096        while matches!(self.peek(), Token::Comma) {
3097            self.advance();
3098            publications.push(self.expect_ident_like()?);
3099        }
3100        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3101            name,
3102            conn_str,
3103            publications,
3104        }))
3105    }
3106
3107    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3108    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3109    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3110    /// that fit `u64`.
3111    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3112    /// (`pg_catalog.default_text_search_config` etc).
3113    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3114        let mut name = self.expect_ident_like()?;
3115        while matches!(self.peek(), Token::Dot) {
3116            self.advance();
3117            let next = self.expect_ident_like()?;
3118            name.push('.');
3119            name.push_str(&next);
3120        }
3121        Ok(name.to_ascii_lowercase())
3122    }
3123
3124    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3125        match self.advance() {
3126            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3127            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3128                Ok(crate::ast::SetValue::Default)
3129            }
3130            Token::Ident(s) | Token::QuotedIdent(s) => {
3131                let mut accum = s;
3132                while matches!(self.peek(), Token::Dot) {
3133                    self.advance();
3134                    let next = self.expect_ident_like()?;
3135                    accum.push('.');
3136                    accum.push_str(&next);
3137                }
3138                Ok(crate::ast::SetValue::Ident(accum))
3139            }
3140            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3141            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3142            // v7.14.0 — MySQL session/user variable RHS
3143            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3144            // Wrap as Ident so the SET handler can record it; the
3145            // engine treats `@VAR` / `@@VAR` values as opaque
3146            // strings.
3147            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3148            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3149            // is the common MySQL preamble shape. Allow a `+` or
3150            // `-` prefix on negative numerics for parity with PG
3151            // (some param defaults are negative).
3152            Token::Minus => match self.advance() {
3153                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3154                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3155                other => Err(self.err(format!(
3156                    "expected numeric after `-` in SET value, got {other:?}"
3157                ))),
3158            },
3159            other => Err(self.err(format!(
3160                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3161            ))),
3162        }
3163    }
3164
3165    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3166        // FOR is a v6.1.2-reserved keyword (Token::For). The
3167        // other two are bare idents — they've never needed lexer
3168        // support and we keep it that way.
3169        if !matches!(self.peek(), Token::For) {
3170            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3171        }
3172        self.advance();
3173        self.expect_keyword_ident("wal")?;
3174        self.expect_keyword_ident("position")?;
3175        let pos = self.expect_u64_literal()?;
3176        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3177        {
3178            self.advance();
3179            self.expect_keyword_ident("timeout")?;
3180            Some(self.expect_u64_literal()?)
3181        } else {
3182            None
3183        };
3184        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3185    }
3186
3187    /// v6.1.7 helper — consume a `Token::Integer` and check it
3188    /// fits `u64`. WAL positions and millisecond timeouts are
3189    /// non-negative.
3190    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3191        match self.advance() {
3192            Token::Integer(n) if n >= 0 => Ok(n as u64),
3193            Token::Integer(n) => Err(ParseError {
3194                message: format!("expected non-negative integer, got {n}"),
3195                token_pos: self.pos.saturating_sub(1),
3196            }),
3197            other => Err(ParseError {
3198                message: format!("expected integer literal, got {other:?}"),
3199                token_pos: self.pos.saturating_sub(1),
3200            }),
3201        }
3202    }
3203
3204    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3205    /// ROLE '<role>' (defaults to readonly). All string slots accept
3206    /// either a quoted ident or a quoted string literal.
3207    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3208        let name = self.expect_ident_or_string()?;
3209        self.expect_keyword_ident("with")?;
3210        self.expect_keyword_ident("password")?;
3211        let password = self.expect_string_literal()?;
3212        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3213            && s.eq_ignore_ascii_case("role")
3214        {
3215            self.advance();
3216            self.expect_string_literal()?
3217        } else {
3218            "readonly".to_string()
3219        };
3220        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3221            name,
3222            password,
3223            role,
3224        }))
3225    }
3226
3227    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3228    /// Caller already consumed the leading `UPDATE` ident.
3229    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3230        let table = self.expect_ident_like()?;
3231        self.expect_keyword_ident("set")?;
3232        let mut assignments = Vec::new();
3233        loop {
3234            let col = self.expect_ident_like()?;
3235            if !matches!(self.peek(), Token::Eq) {
3236                return Err(self.err(format!(
3237                    "expected `=` after column name in UPDATE SET, got {:?}",
3238                    self.peek()
3239                )));
3240            }
3241            self.advance();
3242            let value = self.parse_expr(0)?;
3243            assignments.push((col, value));
3244            if matches!(self.peek(), Token::Comma) {
3245                self.advance();
3246                continue;
3247            }
3248            break;
3249        }
3250        let where_ = if matches!(self.peek(), Token::Where) {
3251            self.advance();
3252            Some(self.parse_expr(0)?)
3253        } else {
3254            None
3255        };
3256        let returning = self.parse_optional_returning()?;
3257        Ok(Statement::Update(crate::ast::UpdateStatement {
3258            table,
3259            assignments,
3260            where_,
3261            returning,
3262        }))
3263    }
3264
3265    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3266    /// the leading `DELETE` ident.
3267    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3268        if !matches!(self.peek(), Token::From) {
3269            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3270        }
3271        self.advance();
3272        let table = self.expect_ident_like()?;
3273        let where_ = if matches!(self.peek(), Token::Where) {
3274            self.advance();
3275            Some(self.parse_expr(0)?)
3276        } else {
3277            None
3278        };
3279        let returning = self.parse_optional_returning()?;
3280        Ok(Statement::Delete(crate::ast::DeleteStatement {
3281            table,
3282            where_,
3283            returning,
3284        }))
3285    }
3286
3287    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3288    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3289    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3290    /// keyword. v7.17 surface:
3291    ///   * source: table reference (subquery source is a follow-up)
3292    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3293    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3294    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3295    ///     order
3296    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3297        // INTO
3298        let is_into_kw = matches!(self.peek(), Token::Into)
3299            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3300        if !is_into_kw {
3301            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3302        }
3303        self.advance();
3304        let target = self.expect_ident_like()?;
3305        // Optional alias — bare ident before USING.
3306        let target_alias = match self.peek() {
3307            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3308                Some(self.expect_ident_like()?)
3309            }
3310            _ => None,
3311        };
3312        // USING
3313        let is_using_kw = matches!(
3314            self.peek(),
3315            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3316        );
3317        if !is_using_kw {
3318            return Err(self.err(format!(
3319                "expected USING after MERGE INTO target, got {:?}",
3320                self.peek()
3321            )));
3322        }
3323        self.advance();
3324        let source = self.expect_ident_like()?;
3325        let source_alias = match self.peek() {
3326            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3327                Some(self.expect_ident_like()?)
3328            }
3329            _ => None,
3330        };
3331        // ON
3332        if !matches!(self.peek(), Token::On) {
3333            return Err(self.err(format!(
3334                "expected ON after MERGE … USING source, got {:?}",
3335                self.peek()
3336            )));
3337        }
3338        self.advance();
3339        let on = self.parse_expr(0)?;
3340        // One or more WHEN clauses.
3341        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3342        loop {
3343            let is_when_kw = matches!(
3344                self.peek(),
3345                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3346            );
3347            if !is_when_kw {
3348                break;
3349            }
3350            self.advance(); // WHEN
3351            // [NOT] MATCHED
3352            let matched = if matches!(self.peek(), Token::Not) {
3353                self.advance();
3354                crate::ast::MergeMatched::NotMatched
3355            } else {
3356                crate::ast::MergeMatched::Matched
3357            };
3358            let is_matched_kw = matches!(
3359                self.peek(),
3360                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3361            );
3362            if !is_matched_kw {
3363                return Err(self.err(format!(
3364                    "expected MATCHED in WHEN clause, got {:?}",
3365                    self.peek()
3366                )));
3367            }
3368            self.advance();
3369            // Optional AND <expr>
3370            let condition = if matches!(self.peek(), Token::And) {
3371                self.advance();
3372                Some(self.parse_expr(0)?)
3373            } else {
3374                None
3375            };
3376            // THEN
3377            let is_then_kw = matches!(
3378                self.peek(),
3379                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3380            );
3381            if !is_then_kw {
3382                return Err(self.err(format!(
3383                    "expected THEN in WHEN clause, got {:?}",
3384                    self.peek()
3385                )));
3386            }
3387            self.advance();
3388            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3389            let action = match self.peek().clone() {
3390                Token::Insert => {
3391                    self.advance();
3392                    // (cols)
3393                    if !matches!(self.peek(), Token::LParen) {
3394                        return Err(self.err(format!(
3395                            "expected '(' after INSERT in MERGE, got {:?}",
3396                            self.peek()
3397                        )));
3398                    }
3399                    self.advance();
3400                    let mut columns: Vec<String> = Vec::new();
3401                    loop {
3402                        columns.push(self.expect_ident_like()?);
3403                        if matches!(self.peek(), Token::Comma) {
3404                            self.advance();
3405                            continue;
3406                        }
3407                        break;
3408                    }
3409                    if !matches!(self.peek(), Token::RParen) {
3410                        return Err(self.err(format!(
3411                            "expected ')' after INSERT column list, got {:?}",
3412                            self.peek()
3413                        )));
3414                    }
3415                    self.advance();
3416                    // VALUES (...)
3417                    if !matches!(self.peek(), Token::Values) {
3418                        return Err(self.err(format!(
3419                            "expected VALUES in MERGE INSERT, got {:?}",
3420                            self.peek()
3421                        )));
3422                    }
3423                    self.advance();
3424                    if !matches!(self.peek(), Token::LParen) {
3425                        return Err(self.err(format!(
3426                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3427                            self.peek()
3428                        )));
3429                    }
3430                    self.advance();
3431                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3432                    loop {
3433                        values.push(self.parse_expr(0)?);
3434                        if matches!(self.peek(), Token::Comma) {
3435                            self.advance();
3436                            continue;
3437                        }
3438                        break;
3439                    }
3440                    if !matches!(self.peek(), Token::RParen) {
3441                        return Err(self.err(format!(
3442                            "expected ')' after MERGE INSERT values, got {:?}",
3443                            self.peek()
3444                        )));
3445                    }
3446                    self.advance();
3447                    if columns.len() != values.len() {
3448                        return Err(self.err(format!(
3449                            "MERGE INSERT column count ({}) ≠ value count ({})",
3450                            columns.len(),
3451                            values.len()
3452                        )));
3453                    }
3454                    crate::ast::MergeAction::Insert { columns, values }
3455                }
3456                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3457                    self.advance();
3458                    // SET
3459                    let is_set_kw = matches!(
3460                        self.peek(),
3461                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3462                    );
3463                    if !is_set_kw {
3464                        return Err(self.err(format!(
3465                            "expected SET after UPDATE in MERGE, got {:?}",
3466                            self.peek()
3467                        )));
3468                    }
3469                    self.advance();
3470                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3471                    loop {
3472                        let col = self.expect_ident_like()?;
3473                        if !matches!(self.peek(), Token::Eq) {
3474                            return Err(self.err(format!(
3475                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3476                                self.peek()
3477                            )));
3478                        }
3479                        self.advance();
3480                        let expr = self.parse_expr(0)?;
3481                        assignments.push((col, expr));
3482                        if matches!(self.peek(), Token::Comma) {
3483                            self.advance();
3484                            continue;
3485                        }
3486                        break;
3487                    }
3488                    crate::ast::MergeAction::Update { assignments }
3489                }
3490                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3491                    self.advance();
3492                    crate::ast::MergeAction::Delete
3493                }
3494                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3495                    self.advance();
3496                    let is_nothing_kw = matches!(
3497                        self.peek(),
3498                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3499                    );
3500                    if !is_nothing_kw {
3501                        return Err(self.err(format!(
3502                            "expected NOTHING after DO in MERGE clause, got {:?}",
3503                            self.peek()
3504                        )));
3505                    }
3506                    self.advance();
3507                    crate::ast::MergeAction::DoNothing
3508                }
3509                other => {
3510                    return Err(self.err(format!(
3511                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3512                    )));
3513                }
3514            };
3515            clauses.push(crate::ast::MergeWhenClause {
3516                matched,
3517                condition,
3518                action,
3519            });
3520        }
3521        if clauses.is_empty() {
3522            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3523        }
3524        Ok(Statement::Merge(crate::ast::MergeStatement {
3525            target,
3526            target_alias,
3527            source,
3528            source_alias,
3529            on,
3530            clauses,
3531        }))
3532    }
3533
3534    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3535    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3536    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3537    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3538    fn parse_optional_returning(
3539        &mut self,
3540    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3541        let is_returning_kw = matches!(
3542            self.peek(),
3543            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3544        );
3545        if !is_returning_kw {
3546            return Ok(None);
3547        }
3548        self.advance();
3549        let mut items = Vec::new();
3550        loop {
3551            items.push(self.parse_select_item()?);
3552            if matches!(self.peek(), Token::Comma) {
3553                self.advance();
3554                continue;
3555            }
3556            break;
3557        }
3558        Ok(Some(items))
3559    }
3560
3561    /// v6.0.4 — parse the tail of an ALTER statement after the
3562    /// leading `ALTER` keyword has been consumed. Only one form is
3563    /// supported in v6.0.4:
3564    ///
3565    /// ```text
3566    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
3567    /// ```
3568    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
3569        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
3570        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
3571        // exclusion) is accepted by stripping the `ONLY` keyword
3572        // before the table parse.
3573        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
3574        // and the long PG-dump tail are accepted as no-ops.
3575        match self.advance() {
3576            Token::Index => {}
3577            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
3578            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
3579            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
3580            Token::Table => {
3581                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3582                    self.advance();
3583                }
3584                return self.parse_alter_table_after_keyword();
3585            }
3586            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
3587                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3588                    self.advance();
3589                }
3590                return self.parse_alter_table_after_keyword();
3591            }
3592            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
3593            // of the silent-noop tail.
3594            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
3595                return self.parse_alter_sequence_after_keyword();
3596            }
3597            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
3598            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
3599            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
3600            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
3601            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
3602            Token::Ident(s) | Token::QuotedIdent(s)
3603                if matches!(
3604                    s.to_ascii_lowercase().as_str(),
3605                    "view"
3606                        | "function"
3607                        | "type"
3608                        | "domain"
3609                        | "database"
3610                        | "role"
3611                        | "schema"
3612                        | "owner"
3613                        | "default"
3614                        | "extension"
3615                        | "materialized"
3616                        | "policy"
3617                        | "publication"
3618                        | "subscription"
3619                ) =>
3620            {
3621                self.consume_until_statement_boundary();
3622                return Ok(Statement::Empty);
3623            }
3624            other => {
3625                return Err(self.err(format!(
3626                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
3627                     after ALTER, got {other:?}"
3628                )));
3629            }
3630        }
3631        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
3632        // (mailrs migrate-042 ships these). The presence of an
3633        // IF EXISTS makes the subsequent name lookup tolerate
3634        // a missing index — engine returns CommandOk no-op.
3635        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3636            let next = self.tokens.get(self.pos + 1);
3637            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3638                self.advance();
3639                self.advance();
3640                true
3641            } else {
3642                false
3643            }
3644        } else {
3645            false
3646        };
3647        let name = self.expect_ident_like()?;
3648        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
3649        // Detect BEFORE the REBUILD path so the existing REBUILD
3650        // arm stays untouched.
3651        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
3652            self.advance();
3653            if matches!(self.peek(), Token::To) {
3654                self.advance();
3655            } else {
3656                self.expect_keyword_ident("to")?;
3657            }
3658            let new = self.expect_ident_like()?;
3659            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3660                name,
3661                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
3662            }));
3663        }
3664        // REBUILD
3665        self.expect_keyword_ident("rebuild")?;
3666        // Optional: WITH (encoding = <enc>)
3667        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3668            self.advance();
3669            if !matches!(self.peek(), Token::LParen) {
3670                return Err(self.err(format!(
3671                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
3672                    self.peek()
3673                )));
3674            }
3675            self.advance();
3676            self.expect_keyword_ident("encoding")?;
3677            if !matches!(self.peek(), Token::Eq) {
3678                return Err(self.err(format!(
3679                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
3680                    self.peek()
3681                )));
3682            }
3683            self.advance();
3684            let enc_ident = match self.advance() {
3685                Token::Ident(s) | Token::QuotedIdent(s) => s,
3686                other => {
3687                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
3688                }
3689            };
3690            let enc = match enc_ident.to_ascii_lowercase().as_str() {
3691                "f32" => VecEncoding::F32,
3692                "sq8" => VecEncoding::Sq8,
3693                "half" => VecEncoding::F16,
3694                other => {
3695                    return Err(self.err(format!(
3696                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
3697                    )));
3698                }
3699            };
3700            if !matches!(self.peek(), Token::RParen) {
3701                return Err(self.err(format!(
3702                    "expected ')' after encoding value, got {:?}",
3703                    self.peek()
3704                )));
3705            }
3706            self.advance();
3707            Some(enc)
3708        } else {
3709            None
3710        };
3711        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3712            name,
3713            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
3714        }))
3715    }
3716
3717    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
3718    /// only `SET` form currently supported; future v6.7.x can add
3719    /// more SET subjects without changing the dispatch shape.
3720    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
3721    /// subactions. Single-subaction shape stays a 1-element vec.
3722    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
3723        let table_name = self.expect_ident_like()?;
3724        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
3725        loop {
3726            let subaction = self.parse_alter_table_subaction()?;
3727            // ADD COLUMN with inline REFERENCES emits both an
3728            // AddColumn and an AddForeignKey subaction; the
3729            // helper returns 1 or 2 items.
3730            targets.extend(subaction);
3731            if matches!(self.peek(), Token::Comma) {
3732                self.advance();
3733                continue;
3734            }
3735            break;
3736        }
3737        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
3738            name: table_name,
3739            targets,
3740        }))
3741    }
3742
3743    /// Parse one ALTER TABLE subaction. Returns a Vec because
3744    /// inline `REFERENCES` on `ADD COLUMN` produces both an
3745    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
3746    fn parse_alter_table_subaction(
3747        &mut self,
3748    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
3749        match self.peek() {
3750            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
3751                self.advance();
3752                let setting = self.expect_ident_like()?;
3753                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
3754                    return Err(self.err(alloc::format!(
3755                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
3756                    )));
3757                }
3758                if !matches!(self.peek(), Token::Eq) {
3759                    return Err(self.err(alloc::format!(
3760                        "expected '=' after hot_tier_bytes, got {:?}",
3761                        self.peek()
3762                    )));
3763                }
3764                self.advance();
3765                let n = self.expect_u64_literal()?;
3766                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
3767            }
3768            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
3769                self.advance();
3770                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
3771                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
3772                // PRIMARY KEY this way; mysqldump emits both.
3773                // Peek-only dispatch (no advance) — `advance()`
3774                // destructively replaces consumed tokens with Eof,
3775                // so saved-pos restore would land on Eofs.
3776                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
3777                {
3778                    // The next-but-one ident is the constraint
3779                    // name; the one after THAT is the kind.
3780                    let kind_pos = self.pos + 2;
3781                    let kind = self.tokens.get(kind_pos).cloned();
3782                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
3783                    {
3784                        let fk = self.parse_table_level_fk()?;
3785                        return Ok(alloc::vec![
3786                            crate::ast::AlterTableTarget::AddForeignKey(fk)
3787                        ]);
3788                    }
3789                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
3790                    {
3791                        self.advance(); // CONSTRAINT
3792                        let _name = self.expect_ident_like()?;
3793                        self.advance(); // PRIMARY
3794                        self.expect_keyword_ident("key")?;
3795                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3796                        return Ok(alloc::vec![
3797                            crate::ast::AlterTableTarget::AddTableConstraint(
3798                                crate::ast::TableConstraint::PrimaryKey {
3799                                    name: None,
3800                                    columns: cols,
3801                                }
3802                            )
3803                        ]);
3804                    }
3805                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
3806                    {
3807                        self.advance(); // CONSTRAINT
3808                        let _name = self.expect_ident_like()?;
3809                        self.advance(); // UNIQUE
3810                        let cols = self.parse_paren_ident_list("UNIQUE")?;
3811                        return Ok(alloc::vec![
3812                            crate::ast::AlterTableTarget::AddTableConstraint(
3813                                crate::ast::TableConstraint::Unique {
3814                                    name: None,
3815                                    columns: cols,
3816                                    nulls_not_distinct: false,
3817                                }
3818                            )
3819                        ]);
3820                    }
3821                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
3822                    {
3823                        self.advance(); // CONSTRAINT
3824                        let _name = self.expect_ident_like()?;
3825                        self.advance(); // CHECK
3826                        if !matches!(self.peek(), Token::LParen) {
3827                            return Err(self.err(alloc::format!(
3828                                "expected '(' after CHECK, got {:?}", self.peek()
3829                            )));
3830                        }
3831                        self.advance();
3832                        let expr = self.parse_expr(0)?;
3833                        if matches!(self.peek(), Token::RParen) {
3834                            self.advance();
3835                        }
3836                        return Ok(alloc::vec![
3837                            crate::ast::AlterTableTarget::AddTableConstraint(
3838                                crate::ast::TableConstraint::Check { name: None, expr }
3839                            )
3840                        ]);
3841                    }
3842                    // Unknown kind — fall through to FK path which
3843                    // produces a descriptive parse error.
3844                }
3845                let is_fk = matches!(
3846                    self.peek(),
3847                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
3848                        || s.eq_ignore_ascii_case("foreign")
3849                );
3850                if is_fk {
3851                    let fk = self.parse_table_level_fk()?;
3852                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
3853                }
3854                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
3855                // (no CONSTRAINT prefix) — same dispatch.
3856                match self.peek().clone() {
3857                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
3858                        self.advance();
3859                        self.expect_keyword_ident("key")?;
3860                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3861                        return Ok(alloc::vec![
3862                            crate::ast::AlterTableTarget::AddTableConstraint(
3863                                crate::ast::TableConstraint::PrimaryKey {
3864                                    name: None,
3865                                    columns: cols,
3866                                }
3867                            )
3868                        ]);
3869                    }
3870                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
3871                        self.advance();
3872                        let cols = self.parse_paren_ident_list("UNIQUE")?;
3873                        return Ok(alloc::vec![
3874                            crate::ast::AlterTableTarget::AddTableConstraint(
3875                                crate::ast::TableConstraint::Unique {
3876                                    name: None,
3877                                    columns: cols,
3878                                    nulls_not_distinct: false,
3879                                }
3880                            )
3881                        ]);
3882                    }
3883                    _ => {}
3884                }
3885                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
3886                    self.advance();
3887                }
3888                let mut if_not_exists = false;
3889                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3890                    self.advance();
3891                    if !matches!(self.peek(), Token::Not) {
3892                        return Err(self.err(alloc::format!(
3893                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
3894                            self.peek()
3895                        )));
3896                    }
3897                    self.advance();
3898                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
3899                        return Err(self.err(alloc::format!(
3900                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
3901                            self.peek()
3902                        )));
3903                    }
3904                    self.advance();
3905                    if_not_exists = true;
3906                }
3907                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
3908                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
3909                // returns ColumnDef + an optional inline FK.
3910                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
3911                let col_name = column.name.clone();
3912                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
3913                    column,
3914                    if_not_exists,
3915                }];
3916                if let Some(mut fk) = col_level_fk {
3917                    if fk.columns.is_empty() {
3918                        fk.columns.push(col_name);
3919                    }
3920                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
3921                }
3922                Ok(out)
3923            }
3924            Token::Drop => {
3925                self.advance();
3926                // v7.13.3 — dispatch on the next token. mailrs round-7
3927                // S8 closed DROP COLUMN; round-6 S7 closed
3928                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
3929                // RESTRICT modifiers.
3930                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
3931                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
3932                let subject = match self.peek() {
3933                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
3934                        self.advance();
3935                        "constraint"
3936                    }
3937                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
3938                        self.advance();
3939                        "column"
3940                    }
3941                    // PG-canonical bare `DROP <col>` without COLUMN
3942                    // keyword is also valid; treat any other ident
3943                    // as the column name.
3944                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
3945                    other => {
3946                        return Err(self.err(alloc::format!(
3947                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
3948                        )));
3949                    }
3950                };
3951                let mut if_exists = false;
3952                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3953                    let n1 = self.tokens.get(self.pos + 1);
3954                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3955                        self.advance();
3956                        self.advance();
3957                        if_exists = true;
3958                    }
3959                }
3960                let name = self.expect_ident_like()?;
3961                let mut cascade = false;
3962                if matches!(
3963                    self.peek(),
3964                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
3965                        || s.eq_ignore_ascii_case("restrict")
3966                ) {
3967                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
3968                    {
3969                        cascade = true;
3970                    }
3971                    self.advance();
3972                }
3973                if subject == "constraint" {
3974                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
3975                        name,
3976                        if_exists,
3977                    }])
3978                } else {
3979                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
3980                        column: name,
3981                        if_exists,
3982                        cascade,
3983                    }])
3984                }
3985            }
3986            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
3987                self.advance();
3988                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
3989                    self.advance();
3990                }
3991                let col_name = self.expect_ident_like()?;
3992                match self.peek() {
3993                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
3994                        self.advance();
3995                    }
3996                    // v7.14.0 — pg_dump emits BIGSERIAL via
3997                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
3998                    // nextval('seq')` (the sequence is created
3999                    // separately). SPG's BIGSERIAL already uses
4000                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4001                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4002                    // engine no-ops by consuming the tail.
4003                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4004                        // ALTER COLUMN col SET DEFAULT … / SET NOT
4005                        // NULL — accept as a no-op on SPG (BIGSERIAL
4006                        // already auto-increments; nullability change
4007                        // would need row scan — deferred).
4008                        self.consume_until_statement_boundary();
4009                        return Ok(Vec::new());
4010                    }
4011                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4012                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4013                        self.consume_until_statement_boundary();
4014                        return Ok(Vec::new());
4015                    }
4016                    other => {
4017                        return Err(self.err(alloc::format!(
4018                            "expected TYPE / SET / DROP after ALTER COLUMN <name>, got {other:?}"
4019                        )));
4020                    }
4021                }
4022                let new_type = self.parse_column_type_name()?;
4023                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4024                {
4025                    self.advance();
4026                    Some(self.parse_expr(0)?)
4027                } else {
4028                    None
4029                };
4030                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4031                    column: col_name,
4032                    new_type,
4033                    using,
4034                }])
4035            }
4036            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4037            // PG also supports `RENAME TO new_table` for table-name
4038            // rename; that surface is deferred (pg_dump never emits
4039            // it). If the first post-RENAME ident is `TO`, the user
4040            // is asking for table rename — error with a clear
4041            // message rather than misparsing `TO` as a column name.
4042            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4043                self.advance();
4044                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4045                // table-name rename (mailrs round-10 A.5 — used
4046                // by migrate-042's `RENAME TO email_contacts`).
4047                // `TO` lexes as Token::To.
4048                if matches!(self.peek(), Token::To)
4049                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4050                {
4051                    self.advance();
4052                    let new = self.expect_ident_like()?;
4053                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4054                        new,
4055                    }]);
4056                }
4057                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4058                    self.advance();
4059                }
4060                let old = self.expect_ident_like()?;
4061                // `TO` is a reserved keyword token; accept both
4062                // Token::To and Token::Ident("to") for consistency.
4063                if matches!(self.peek(), Token::To) {
4064                    self.advance();
4065                } else {
4066                    self.expect_keyword_ident("to")?;
4067                }
4068                let new = self.expect_ident_like()?;
4069                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4070                    old,
4071                    new,
4072                }])
4073            }
4074            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4075            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4076            // every data block with these. Real disable semantics —
4077            // not no-op — because reload correctness assumes the
4078            // triggers don't fire (rows already carry their
4079            // computed values from prod).
4080            Token::Ident(s)
4081                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4082            {
4083                let enabled = s.eq_ignore_ascii_case("enable");
4084                self.advance();
4085                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4086                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4087                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4088                // pg_dump output) — anything else falls through to
4089                // the catch-all error below.
4090                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4091                    return Err(self.err(alloc::format!(
4092                        "expected TRIGGER after {}, got {:?}",
4093                        if enabled { "ENABLE" } else { "DISABLE" },
4094                        self.peek()
4095                    )));
4096                }
4097                self.advance();
4098                // `ALL` lexes as Token::All (reserved); also
4099                // accept Token::Ident("all") for symmetry.
4100                let which = if matches!(self.peek(), Token::All)
4101                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4102                {
4103                    self.advance();
4104                    crate::ast::TriggerSelector::All
4105                } else {
4106                    let name = self.expect_ident_like()?;
4107                    crate::ast::TriggerSelector::Named(name)
4108                };
4109                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4110                    which,
4111                    enabled,
4112                }])
4113            }
4114            other => Err(self.err(alloc::format!(
4115                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4116            ))),
4117        }
4118    }
4119
4120    /// v7.16.2 — peek for `information_schema.<tbl>` /
4121    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4122    /// three tokens + return a synthetic table name the engine's
4123    /// SELECT path recognises as a virtual view. Returns `None`
4124    /// when the head doesn't look like a meta-qualified name.
4125    /// Used by `parse_table_ref` to bypass the
4126    /// `expect_ident_like` schema-strip for these specific PG
4127    /// meta schemas (mailrs round-10 A.3).
4128    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4129        // Extract the schema name. Must be a plain ident token.
4130        let schema = match self.tokens.get(self.pos) {
4131            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4132            _ => return None,
4133        };
4134        // Dot.
4135        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4136            return None;
4137        }
4138        // The table-side ident may lex as a reserved keyword
4139        // (e.g. `Token::Tables`). Tolerate the common ones via a
4140        // helper that reads the trailing token's underlying name.
4141        let tbl = match self.tokens.get(self.pos + 2)? {
4142            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4143            Token::Tables => "tables".to_string(),
4144            // Other PG meta table names that may collide with
4145            // reserved keywords land here as needed.
4146            _ => return None,
4147        };
4148        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4149        // names so the synthetic name doesn't double-prefix
4150        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4151        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4152            ("__spg_info_", tbl.to_ascii_lowercase())
4153        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4154            let bare = tbl
4155                .to_ascii_lowercase()
4156                .strip_prefix("pg_")
4157                .map(alloc::string::String::from)
4158                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4159            ("__spg_pg_", bare)
4160        } else if schema.eq_ignore_ascii_case("mysql") {
4161            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4162            // (`mysql.user`, `mysql.db`). Same synthetic-name
4163            // shape as pg_catalog.
4164            ("__spg_mysql_", tbl.to_ascii_lowercase())
4165        } else {
4166            return None;
4167        };
4168        self.advance(); // schema
4169        self.advance(); // dot
4170        self.advance(); // tbl
4171        Some(alloc::format!("{prefix}{normalised}"))
4172    }
4173
4174    /// Consume a bare ident if its lowercase matches `kw`, else err.
4175    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4176        match self.advance() {
4177            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4178            other => Err(ParseError {
4179                message: format!("expected {kw:?}, got {other:?}"),
4180                token_pos: self.pos.saturating_sub(1),
4181            }),
4182        }
4183    }
4184
4185    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4186    /// literal (`'foo'`) — same shape used by CREATE USER for the
4187    /// username slot.
4188    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4189        match self.advance() {
4190            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4191            other => Err(ParseError {
4192                message: format!("expected identifier or string, got {other:?}"),
4193                token_pos: self.pos.saturating_sub(1),
4194            }),
4195        }
4196    }
4197
4198    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4199        match self.advance() {
4200            Token::String(s) => Ok(s),
4201            other => Err(ParseError {
4202                message: format!("expected quoted string, got {other:?}"),
4203                token_pos: self.pos.saturating_sub(1),
4204            }),
4205        }
4206    }
4207
4208    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4209        // Caller dispatches on Token::Select; the inner helper handles
4210        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4211        // get a fresh bare-select parse and may not have their own ORDER
4212        // BY / LIMIT.
4213        let mut head = self.parse_bare_select()?;
4214        while matches!(self.peek(), Token::Union) {
4215            self.advance();
4216            let kind = if matches!(self.peek(), Token::All) {
4217                self.advance();
4218                UnionKind::All
4219            } else {
4220                UnionKind::Distinct
4221            };
4222            let peer = self.parse_bare_select()?;
4223            head.unions.push((kind, peer));
4224        }
4225        head.order_by = if matches!(self.peek(), Token::Order) {
4226            self.advance();
4227            if !matches!(self.peek(), Token::By) {
4228                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4229            }
4230            self.advance();
4231            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4232            // `<expr> [ASC|DESC]` items.
4233            let mut keys = Vec::new();
4234            loop {
4235                let expr = self.parse_expr(0)?;
4236                let desc = if matches!(self.peek(), Token::Desc) {
4237                    self.advance();
4238                    true
4239                } else if matches!(self.peek(), Token::Asc) {
4240                    self.advance();
4241                    false
4242                } else {
4243                    false
4244                };
4245                keys.push(OrderBy { expr, desc });
4246                if matches!(self.peek(), Token::Comma) {
4247                    self.advance();
4248                } else {
4249                    break;
4250                }
4251            }
4252            keys
4253        } else {
4254            Vec::new()
4255        };
4256        head.limit = if matches!(self.peek(), Token::Limit) {
4257            self.advance();
4258            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4259            // PG synonyms for "no limit". Treat both as None
4260            // (no head.limit set) so the engine's existing
4261            // unlimited-result path takes over. Reject was the
4262            // pre-5.1 behaviour and broke pg_dump-flavoured
4263            // tooling that occasionally emits LIMIT NULL.
4264            if self.consume_limit_unbounded_sentinel() {
4265                None
4266            } else {
4267                Some(self.parse_limit_expr("LIMIT")?)
4268            }
4269        } else {
4270            None
4271        };
4272        head.offset = if matches!(self.peek(), Token::Offset) {
4273            self.advance();
4274            // PG also accepts an optional `ROW` / `ROWS` trailer
4275            // after the offset value (`OFFSET 10 ROWS`). The
4276            // FETCH-FIRST branch below relies on the same.
4277            let off = self.parse_limit_expr("OFFSET")?;
4278            self.consume_optional_rows_keyword();
4279            Some(off)
4280        } else {
4281            None
4282        };
4283        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4284        // the SQL-standard alias for LIMIT. PG accepts both
4285        // spellings interchangeably; pg_dump emits FETCH FIRST in
4286        // newer versions. We map it onto `head.limit` so the
4287        // engine path is unified.
4288        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4289        {
4290            self.advance(); // FETCH
4291            // `FIRST` or `NEXT` (both legal per SQL standard).
4292            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4293                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4294            {
4295                self.advance();
4296            }
4297            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4298            // implicit 1 — but we always consume one if present).
4299            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4300                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4301            {
4302                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4303                crate::ast::LimitExpr::Literal(1)
4304            } else {
4305                self.parse_limit_expr("FETCH FIRST")?
4306            };
4307            // Eat `ROW` / `ROWS` if not already consumed above.
4308            self.consume_optional_rows_keyword();
4309            // Optional `ONLY` (the spec form) — or the SQL:2008
4310            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4311            // now honours WITH TIES by extending past the LIMIT
4312            // truncation point through every row that shares the
4313            // last-kept row's ORDER BY key.
4314            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4315                if s.eq_ignore_ascii_case("only"))
4316            {
4317                self.advance();
4318            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4319                if s.eq_ignore_ascii_case("with"))
4320            {
4321                self.advance(); // WITH
4322                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4323                    if s.eq_ignore_ascii_case("ties"))
4324                {
4325                    self.advance();
4326                    head.limit_with_ties = true;
4327                }
4328            }
4329            head.limit = Some(count);
4330        }
4331        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4332        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4333        //       [ OF table_name [, …] ]
4334        //       [ NOWAIT | SKIP LOCKED ]
4335        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4336        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4337        // SELECT already returns a consistent snapshot — so these
4338        // are accept-and-discard: the parser absorbs them so
4339        // mailrs / Rails / Django code paths that emit `SELECT
4340        // … FOR UPDATE` for advisory pessimistic locking load
4341        // without a parser error. The on-disk locking model is
4342        // unchanged; callers that rely on FOR UPDATE for read-
4343        // through-write ordering still get the right answer
4344        // because SPG serialises writes anyway.
4345        self.consume_optional_for_lock_clauses();
4346        Ok(Statement::Select(head))
4347    }
4348
4349    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4350    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4351    /// LOCKED ]` trailers. Each clause is fully accepted and
4352    /// discarded — SPG's single-writer model already satisfies the
4353    /// callers' implicit ordering requirement. Stops at the first
4354    /// token that isn't `FOR`.
4355    fn consume_optional_for_lock_clauses(&mut self) {
4356        while matches!(self.peek(), Token::For) {
4357            self.advance(); // FOR
4358            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4359            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4360            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4361                if s.eq_ignore_ascii_case("no"))
4362            {
4363                self.advance(); // NO
4364                // The next ident should be KEY but be generous;
4365                // anything followed by UPDATE/SHARE is accepted.
4366                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4367                    if s.eq_ignore_ascii_case("key"))
4368                {
4369                    self.advance(); // KEY
4370                }
4371            }
4372            // `KEY` prefix (PG `FOR KEY SHARE`).
4373            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4374                if s.eq_ignore_ascii_case("key"))
4375            {
4376                self.advance(); // KEY
4377            }
4378            // Lock-strength keyword: UPDATE / SHARE. Required, but
4379            // we're lenient — an unexpected token here just bails
4380            // (we already consumed FOR; caller's downstream
4381            // dispatch will error if anything actually depends on
4382            // the trailing tokens).
4383            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4384                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4385            {
4386                self.advance();
4387            } else {
4388                // FOR by itself (or `FOR KEY` with nothing after) —
4389                // give up on the lock-clause path. We've already
4390                // advanced past FOR; further attempts to parse
4391                // here would clobber state.
4392                return;
4393            }
4394            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4395            // joining and locking only a subset of tables.
4396            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4397                if s.eq_ignore_ascii_case("of"))
4398            {
4399                self.advance(); // OF
4400                #[allow(clippy::while_let_loop)]
4401                loop {
4402                    match self.peek() {
4403                        Token::Ident(_) | Token::QuotedIdent(_) => {
4404                            self.advance();
4405                            // Optional schema-qualified `schema.table`.
4406                            if matches!(self.peek(), Token::Dot) {
4407                                self.advance();
4408                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4409                                    self.advance();
4410                                }
4411                            }
4412                        }
4413                        _ => break,
4414                    }
4415                    if matches!(self.peek(), Token::Comma) {
4416                        self.advance();
4417                    } else {
4418                        break;
4419                    }
4420                }
4421            }
4422            // Optional `NOWAIT` | `SKIP LOCKED`.
4423            match self.peek().clone() {
4424                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4425                    self.advance();
4426                }
4427                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4428                    self.advance(); // SKIP
4429                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4430                        if s.eq_ignore_ascii_case("locked"))
4431                    {
4432                        self.advance(); // LOCKED
4433                    }
4434                }
4435                _ => {}
4436            }
4437            // Loop: PG allows multiple FOR clauses chained.
4438        }
4439    }
4440
4441    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4442    /// Bind value gets resolved during prepared-statement Execute;
4443    /// the Pratt expression parser would over-accept here (e.g.
4444    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4445    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4446    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4447    /// when one was consumed; caller skips the regular
4448    /// limit-value parse and leaves `head.limit` at None.
4449    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4450        if matches!(self.peek(), Token::Null) {
4451            self.advance();
4452            return true;
4453        }
4454        if matches!(self.peek(), Token::All) {
4455            self.advance();
4456            return true;
4457        }
4458        false
4459    }
4460
4461    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4462    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4463    /// SQL-standard shape. No-op when missing.
4464    fn consume_optional_rows_keyword(&mut self) {
4465        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4466            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4467        {
4468            self.advance();
4469        }
4470    }
4471
4472    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4473        match self.advance() {
4474            Token::Integer(n) if n >= 0 => u32::try_from(n)
4475                .map(crate::ast::LimitExpr::Literal)
4476                .map_err(|_| ParseError {
4477                    message: alloc::format!("{label} value too large: {n}"),
4478                    token_pos: self.pos.saturating_sub(1),
4479                }),
4480            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4481            other => Err(ParseError {
4482                message: alloc::format!(
4483                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4484                ),
4485                token_pos: self.pos.saturating_sub(1),
4486            }),
4487        }
4488    }
4489
4490    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4491    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4492    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4493    /// `parse_select_stmt` is responsible for filling those in.
4494    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4495        if !matches!(self.peek(), Token::Select) {
4496            return Err(self.err(format!(
4497                "expected SELECT to start a query block, got {:?}",
4498                self.peek()
4499            )));
4500        }
4501        self.advance();
4502        let distinct = if matches!(self.peek(), Token::Distinct) {
4503            self.advance();
4504            true
4505        } else {
4506            false
4507        };
4508        let items = self.parse_select_list()?;
4509        let from = if matches!(self.peek(), Token::From) {
4510            self.advance();
4511            Some(self.parse_from_clause()?)
4512        } else {
4513            None
4514        };
4515        let where_ = if matches!(self.peek(), Token::Where) {
4516            self.advance();
4517            Some(self.parse_expr(0)?)
4518        } else {
4519            None
4520        };
4521        let mut group_by_all = false;
4522        let group_by = if matches!(self.peek(), Token::Group) {
4523            self.advance();
4524            if !matches!(self.peek(), Token::By) {
4525                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4526            }
4527            self.advance();
4528            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4529            // every non-aggregate SELECT-list item later.
4530            if matches!(self.peek(), Token::All) {
4531                self.advance();
4532                group_by_all = true;
4533                None
4534            } else {
4535                let mut groups = Vec::new();
4536                loop {
4537                    groups.push(self.parse_expr(0)?);
4538                    if matches!(self.peek(), Token::Comma) {
4539                        self.advance();
4540                    } else {
4541                        break;
4542                    }
4543                }
4544                Some(groups)
4545            }
4546        } else {
4547            None
4548        };
4549        let having = if matches!(self.peek(), Token::Having) {
4550            self.advance();
4551            Some(self.parse_expr(0)?)
4552        } else {
4553            None
4554        };
4555        Ok(SelectStatement {
4556            ctes: Vec::new(),
4557            distinct,
4558            items,
4559            from,
4560            where_,
4561            group_by,
4562            group_by_all,
4563            having,
4564            unions: Vec::new(),
4565            order_by: Vec::new(),
4566            limit: None,
4567            offset: None,
4568            limit_with_ties: false,
4569        })
4570    }
4571
4572    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4573        // Caller already consumed CREATE; we're sitting on TABLE.
4574        debug_assert!(matches!(self.peek(), Token::Table));
4575        self.advance();
4576        let if_not_exists = self.consume_if_not_exists();
4577        let name = self.expect_ident_like()?;
4578        if !matches!(self.peek(), Token::LParen) {
4579            return Err(self.err(format!(
4580                "expected '(' after table name, got {:?}",
4581                self.peek()
4582            )));
4583        }
4584        self.advance();
4585        let mut columns = Vec::new();
4586        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
4587        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
4588        loop {
4589            // v7.6.0 / v7.9.18 — distinguish table-level constraint
4590            // clauses from column definitions. Constraints start
4591            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
4592            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
4593            // a column.
4594            if self.peek_table_level_pk_start() {
4595                table_constraints.push(self.parse_table_level_primary_key()?);
4596            } else if self.peek_table_level_unique_start() {
4597                table_constraints.push(self.parse_table_level_unique()?);
4598            } else if self.peek_table_level_check_start() {
4599                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
4600                table_constraints.push(self.parse_table_level_check()?);
4601            } else if self.peek_mysql_inline_key_start() {
4602                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
4603                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
4604                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
4605                // inside the column list. Skip name + paren list;
4606                // for UNIQUE KEY, register as a UC.
4607                if let Some(uc) = self.parse_mysql_inline_key()? {
4608                    table_constraints.push(uc);
4609                }
4610            } else if self.peek_constraint_or_fk_start() {
4611                foreign_keys.push(self.parse_table_level_fk()?);
4612            } else {
4613                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
4614                // v7.13.0 — fold inline UNIQUE / CHECK column
4615                // constraints into table-level entries so the
4616                // engine path stays uniform.
4617                if col.is_unique {
4618                    table_constraints.push(crate::ast::TableConstraint::Unique {
4619                        name: None,
4620                        columns: alloc::vec![col.name.clone()],
4621                        nulls_not_distinct: false,
4622                    });
4623                }
4624                if let Some(check_expr) = col.check.clone() {
4625                    table_constraints.push(crate::ast::TableConstraint::Check {
4626                        name: None,
4627                        expr: check_expr,
4628                    });
4629                }
4630                columns.push(col);
4631                if let Some(fk) = col_level_fk {
4632                    foreign_keys.push(fk);
4633                }
4634            }
4635            match self.peek() {
4636                Token::Comma => {
4637                    self.advance();
4638                }
4639                Token::RParen => {
4640                    self.advance();
4641                    break;
4642                }
4643                other => {
4644                    return Err(
4645                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
4646                    );
4647                }
4648            }
4649        }
4650        if columns.is_empty() {
4651            return Err(self.err("CREATE TABLE requires at least one column".into()));
4652        }
4653        // v7.14.0 — consume MySQL/MariaDB table options after the
4654        // closing `)`. mysqldump emits things like
4655        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
4656        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
4657        // SPG accepts all forms as no-ops (each option is
4658        // `<ident> [=] <ident-or-string>` separated by whitespace).
4659        self.consume_mysql_table_options();
4660        Ok(Statement::CreateTable(CreateTableStatement {
4661            name,
4662            columns,
4663            if_not_exists,
4664            foreign_keys,
4665            table_constraints,
4666        }))
4667    }
4668
4669    /// v7.14.0 — true when the next tokens look like an inline
4670    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
4671    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
4672    /// — each followed by an optional name + `(...)`. Critical:
4673    /// a column NAMED `key` / `index` (PG accepts as ident) must
4674    /// NOT be mistaken for the KEY constraint shape. We disambig
4675    /// by requiring the keyword to be followed by either `(` or
4676    /// `<ident> (`.
4677    fn peek_mysql_inline_key_start(&self) -> bool {
4678        let cur = self.peek();
4679        // Shapes:
4680        //   KEY (cols)
4681        //   KEY name (cols)
4682        //   INDEX (cols)
4683        //   INDEX name (cols)
4684        //   UNIQUE KEY [name] (cols)
4685        //   UNIQUE INDEX [name] (cols)
4686        //   FULLTEXT [KEY|INDEX] [name] (cols)
4687        //   SPATIAL [KEY|INDEX] [name] (cols)
4688        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
4689            // tokens at skip = the position AFTER the index-form
4690            // keywords (KEY/INDEX) have been consumed.
4691            match self.tokens.get(skip) {
4692                Some(Token::LParen) => true,
4693                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
4694                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
4695                }
4696                _ => false,
4697            }
4698        };
4699        // `INDEX` lexes as Token::Index (reserved), not as
4700        // Token::Ident("index"). Both shapes count as a KEY/INDEX
4701        // start; the peek helper below handles either.
4702        let is_key_or_index_tok = |t: &Token| -> bool {
4703            matches!(t, Token::Index)
4704                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
4705        };
4706        match cur {
4707            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
4708            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4709                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
4710            }
4711            Token::Ident(s)
4712                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
4713            {
4714                let nxt = self.tokens.get(self.pos + 1);
4715                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
4716                    self.pos + 2
4717                } else {
4718                    self.pos + 1
4719                };
4720                after_keyword_followed_by_paren_or_ident_paren(after_after)
4721            }
4722            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4723                let nxt = self.tokens.get(self.pos + 1);
4724                if !nxt.is_some_and(is_key_or_index_tok) {
4725                    return false;
4726                }
4727                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
4728            }
4729            _ => false,
4730        }
4731    }
4732
4733    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
4734    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
4735    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
4736    /// returns Some(TableConstraint::Index) so the engine builds
4737    /// a real BTree index on the leading column (mysqldump
4738    /// `KEY idx_posts_author (author_id)` shape).
4739    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
4740    /// (the storage layer has no matching AM).
4741    fn parse_mysql_inline_key(
4742        &mut self,
4743    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
4744        // Detect UNIQUE prefix.
4745        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
4746        {
4747            self.advance();
4748            true
4749        } else {
4750            false
4751        };
4752        // Consume FULLTEXT / SPATIAL prefix and record which one
4753        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
4754        // dedicated TableConstraint variant so the engine can
4755        // build a tsvector-GIN; SPATIAL still has no matching
4756        // AM, so it falls back to accept-as-no-op.
4757        let mut is_fulltext = false;
4758        let mut is_spatial = false;
4759        if let Token::Ident(s) = self.peek().clone() {
4760            if s.eq_ignore_ascii_case("fulltext") {
4761                self.advance();
4762                is_fulltext = true;
4763            } else if s.eq_ignore_ascii_case("spatial") {
4764                self.advance();
4765                is_spatial = true;
4766            }
4767        }
4768        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
4769        // (reserved); accept either token shape.
4770        match self.peek() {
4771            Token::Index => {
4772                self.advance();
4773            }
4774            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4775                self.advance();
4776            }
4777            other => {
4778                return Err(self.err(alloc::format!(
4779                    "expected KEY/INDEX in inline index declaration, got {other:?}"
4780                )));
4781            }
4782        }
4783        // Optional index name (an ident before the `(`).
4784        // v7.15.0 — capture the name when present so the engine
4785        // builds the secondary index under the user's chosen
4786        // name (matches mysqldump's `KEY idx_x (col)` shape).
4787        let mut idx_name: Option<String> = None;
4788        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
4789            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
4790        {
4791            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
4792                idx_name = Some(s);
4793            }
4794        }
4795        // Optional `USING BTREE` / `USING HASH` (MySQL).
4796        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
4797            self.advance();
4798            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4799                self.advance();
4800            }
4801        }
4802        // Required column list `(col [, col]*)`.
4803        if !matches!(self.peek(), Token::LParen) {
4804            return Err(self.err(alloc::format!(
4805                "expected '(' in inline KEY/INDEX, got {:?}",
4806                self.peek()
4807            )));
4808        }
4809        self.advance();
4810        let mut cols: Vec<String> = Vec::new();
4811        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
4812            self.advance();
4813            cols.push(s);
4814            // Skip optional `(length)` per-column prefix.
4815            if matches!(self.peek(), Token::LParen) {
4816                let mut depth = 1usize;
4817                self.advance();
4818                while depth > 0 {
4819                    match self.peek() {
4820                        Token::LParen => depth += 1,
4821                        Token::RParen => depth -= 1,
4822                        Token::Eof => break,
4823                        _ => {}
4824                    }
4825                    self.advance();
4826                }
4827            }
4828            // Skip optional ASC / DESC.
4829            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
4830                || matches!(self.peek(), Token::Asc | Token::Desc)
4831            {
4832                self.advance();
4833            }
4834            if matches!(self.peek(), Token::Comma) {
4835                self.advance();
4836                continue;
4837            }
4838            break;
4839        }
4840        if matches!(self.peek(), Token::RParen) {
4841            self.advance();
4842        }
4843        // Trailing options on the inline index — comment / etc.
4844        // Skip until comma or `)`.
4845        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
4846            self.advance();
4847        }
4848        if cols.is_empty() {
4849            return Ok(None);
4850        }
4851        if is_unique {
4852            // Carry the captured idx_name on UNIQUE too so future
4853            // engine work can name the underlying BTree
4854            // accordingly; today the unique-constraint installer
4855            // synthesises the name itself, but Display round-trip
4856            // benefits from preserving it.
4857            Ok(Some(crate::ast::TableConstraint::Unique {
4858                name: idx_name,
4859                columns: cols,
4860                nulls_not_distinct: false,
4861            }))
4862        } else if is_fulltext {
4863            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
4864            // routes through `TableConstraint::FulltextIndex`;
4865            // the engine builds a tsvector-GIN over each named
4866            // column so MATCH AGAINST gets a real inverted
4867            // index instead of a silently-dropped declaration.
4868            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
4869                name: idx_name,
4870                columns: cols,
4871            }))
4872        } else if is_spatial {
4873            // SPG has no native SPATIAL AM. Accept-as-no-op
4874            // (declaration is parsed, but no index is built).
4875            Ok(None)
4876        } else {
4877            // v7.15.0 — plain KEY / INDEX builds a real BTree
4878            // secondary index.
4879            Ok(Some(crate::ast::TableConstraint::Index {
4880                name: idx_name,
4881                columns: cols,
4882            }))
4883        }
4884    }
4885
4886    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
4887    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
4888    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
4889    /// (in any order, separated by whitespace).
4890    fn consume_mysql_table_options(&mut self) {
4891        loop {
4892            // Heuristic: a table option is an ident (or `DEFAULT`
4893            // reserved keyword) followed by `=` and an
4894            // ident / string / integer.
4895            let name_lc = match self.peek().clone() {
4896                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
4897                Token::Default => alloc::string::String::from("default"),
4898                _ => break,
4899            };
4900            let known = matches!(
4901                name_lc.as_str(),
4902                "engine"
4903                    | "default"
4904                    | "charset"
4905                    | "collate"
4906                    | "auto_increment"
4907                    | "row_format"
4908                    | "comment"
4909                    | "pack_keys"
4910                    | "stats_persistent"
4911                    | "stats_auto_recalc"
4912                    | "stats_sample_pages"
4913                    | "key_block_size"
4914                    | "tablespace"
4915                    | "min_rows"
4916                    | "max_rows"
4917                    | "checksum"
4918                    | "delay_key_write"
4919                    | "insert_method"
4920                    | "data"
4921                    | "index"
4922                    | "encryption"
4923                    | "compression"
4924            );
4925            if !known {
4926                break;
4927            }
4928            self.advance(); // option name
4929            // `DEFAULT` optional prefix is followed by `CHARSET` /
4930            // `COLLATE`; consume the next ident too.
4931            if name_lc == "default" {
4932                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4933                    self.advance();
4934                }
4935            }
4936            if matches!(self.peek(), Token::Eq) {
4937                self.advance();
4938            }
4939            match self.peek() {
4940                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
4941                    self.advance();
4942                }
4943                _ => {}
4944            }
4945        }
4946    }
4947
4948    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
4949    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
4950    /// sure (otherwise a column literally named `primary` would
4951    /// be mistaken).
4952    fn peek_table_level_pk_start(&self) -> bool {
4953        let cur = self.peek();
4954        let nxt = self.tokens.get(self.pos + 1);
4955        let nxt2 = self.tokens.get(self.pos + 2);
4956        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
4957        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
4958        let is_lparen = matches!(nxt2, Some(Token::LParen));
4959        is_primary && is_key && is_lparen
4960    }
4961
4962    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
4963    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
4964    /// (mailrs round-5 G10).
4965    fn peek_table_level_unique_start(&self) -> bool {
4966        let cur = self.peek();
4967        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
4968        if !is_unique {
4969            return false;
4970        }
4971        let n1 = self.tokens.get(self.pos + 1);
4972        // Plain `UNIQUE (…)`.
4973        if matches!(n1, Some(Token::LParen)) {
4974            return true;
4975        }
4976        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
4977        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
4978        if !is_nulls {
4979            return false;
4980        }
4981        let n2 = self.tokens.get(self.pos + 2);
4982        let n3 = self.tokens.get(self.pos + 3);
4983        let n4 = self.tokens.get(self.pos + 4);
4984        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
4985        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
4986            return true;
4987        }
4988        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
4989        if matches!(n2, Some(Token::Not))
4990            && matches!(n3, Some(Token::Distinct))
4991            && matches!(n4, Some(Token::LParen))
4992        {
4993            return true;
4994        }
4995        false
4996    }
4997
4998    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
4999        self.advance(); // PRIMARY
5000        self.advance(); // KEY
5001        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5002        Ok(crate::ast::TableConstraint::PrimaryKey {
5003            name: None,
5004            columns,
5005        })
5006    }
5007
5008    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5009        self.advance(); // UNIQUE
5010        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5011        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5012        // is `NULLS DISTINCT` per the SQL standard.
5013        let mut nulls_not_distinct = false;
5014        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5015            let n1 = self.tokens.get(self.pos + 1);
5016            let n2 = self.tokens.get(self.pos + 2);
5017            let is_not = matches!(n1, Some(Token::Not));
5018            let is_distinct = matches!(n2, Some(Token::Distinct));
5019            if is_not && is_distinct {
5020                self.advance(); // NULLS
5021                self.advance(); // NOT
5022                self.advance(); // DISTINCT
5023                nulls_not_distinct = true;
5024            } else if matches!(n1, Some(Token::Distinct)) {
5025                self.advance(); // NULLS
5026                self.advance(); // DISTINCT
5027            }
5028        }
5029        let columns = self.parse_paren_ident_list("UNIQUE")?;
5030        Ok(crate::ast::TableConstraint::Unique {
5031            name: None,
5032            columns,
5033            nulls_not_distinct,
5034        })
5035    }
5036
5037    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5038    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5039    /// expression.
5040    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5041        self.advance(); // CHECK
5042        if !matches!(self.peek(), Token::LParen) {
5043            return Err(self.err(alloc::format!(
5044                "expected '(' after CHECK, got {:?}",
5045                self.peek()
5046            )));
5047        }
5048        self.advance();
5049        let expr = self.parse_expr(0)?;
5050        if !matches!(self.peek(), Token::RParen) {
5051            return Err(self.err(alloc::format!(
5052                "expected ')' to close CHECK predicate, got {:?}",
5053                self.peek()
5054            )));
5055        }
5056        self.advance();
5057        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5058    }
5059
5060    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5061    fn peek_table_level_check_start(&self) -> bool {
5062        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5063    }
5064
5065    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5066        if !matches!(self.peek(), Token::LParen) {
5067            return Err(self.err(alloc::format!(
5068                "expected '(' after {ctx}, got {:?}",
5069                self.peek()
5070            )));
5071        }
5072        self.advance();
5073        let mut out = Vec::new();
5074        loop {
5075            out.push(self.expect_ident_like()?);
5076            match self.peek() {
5077                Token::Comma => {
5078                    self.advance();
5079                }
5080                Token::RParen => {
5081                    self.advance();
5082                    break;
5083                }
5084                other => {
5085                    return Err(self.err(alloc::format!(
5086                        "expected ',' or ')' in {ctx} list, got {other:?}"
5087                    )));
5088                }
5089            }
5090        }
5091        if out.is_empty() {
5092            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5093        }
5094        Ok(out)
5095    }
5096
5097    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5098    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5099    /// table-level FK; a column def never starts with either keyword
5100    /// (column names are not in this reserved set).
5101    fn peek_constraint_or_fk_start(&self) -> bool {
5102        let is_constraint_kw = matches!(
5103            self.peek(),
5104            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5105        );
5106        let is_foreign_kw = matches!(
5107            self.peek(),
5108            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5109        );
5110        is_constraint_kw || is_foreign_kw
5111    }
5112
5113    /// v7.6.0 — parse a table-level FK clause:
5114    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5115    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5116    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5117        let mut name: Option<String> = None;
5118        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5119            self.advance();
5120            name = Some(self.expect_ident_like()?);
5121        }
5122        // `FOREIGN`
5123        match self.advance() {
5124            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5125            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5126        }
5127        // `KEY`
5128        match self.advance() {
5129            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5130            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5131        }
5132        // `(col, col, ...)`
5133        if !matches!(self.peek(), Token::LParen) {
5134            return Err(self.err(format!(
5135                "expected '(' after FOREIGN KEY, got {:?}",
5136                self.peek()
5137            )));
5138        }
5139        self.advance();
5140        let mut columns = Vec::new();
5141        loop {
5142            columns.push(self.expect_ident_like()?);
5143            match self.peek() {
5144                Token::Comma => {
5145                    self.advance();
5146                }
5147                Token::RParen => {
5148                    self.advance();
5149                    break;
5150                }
5151                other => {
5152                    return Err(self.err(format!(
5153                        "expected ',' or ')' in FK column list, got {other:?}"
5154                    )));
5155                }
5156            }
5157        }
5158        if columns.is_empty() {
5159            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5160        }
5161        let (parent_table, parent_columns, on_delete, on_update) =
5162            self.parse_references_tail(columns.len())?;
5163        Ok(ForeignKeyConstraint {
5164            name,
5165            columns,
5166            parent_table,
5167            parent_columns,
5168            on_delete,
5169            on_update,
5170        })
5171    }
5172
5173    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5174    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5175    /// the local column count, used to default the parent column
5176    /// list when omitted (SQL spec: parent's PK is implied).
5177    fn parse_references_tail(
5178        &mut self,
5179        expected_arity: usize,
5180    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5181        match self.advance() {
5182            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5183            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5184        }
5185        let parent_table = self.expect_ident_like()?;
5186        let mut parent_columns: Vec<String> = Vec::new();
5187        if matches!(self.peek(), Token::LParen) {
5188            self.advance();
5189            loop {
5190                parent_columns.push(self.expect_ident_like()?);
5191                match self.peek() {
5192                    Token::Comma => {
5193                        self.advance();
5194                    }
5195                    Token::RParen => {
5196                        self.advance();
5197                        break;
5198                    }
5199                    other => {
5200                        return Err(self.err(format!(
5201                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5202                        )));
5203                    }
5204                }
5205            }
5206        }
5207        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5208            return Err(self.err(format!(
5209                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5210                expected_arity,
5211                parent_columns.len()
5212            )));
5213        }
5214        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5215        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5216        // <action>` / `ON UPDATE <action>` in either order. PG /
5217        // pg_dump emits the timing clause AFTER the ON clauses
5218        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5219        // but the SQL spec allows either order. We loop over
5220        // every possible trailer and dispatch on the next token,
5221        // stopping when nothing matches. Phase 3.1 changes the
5222        // bare DEFERRABLE form from hard-error to accept-as-
5223        // immediate; SPG is single-writer with no deferred-
5224        // constraint window so the runtime semantics are always
5225        // immediate even when INITIALLY DEFERRED is requested.
5226        let mut on_delete = FkAction::Restrict;
5227        let mut on_update = FkAction::Restrict;
5228        let mut seen_on_delete = false;
5229        let mut seen_on_update = false;
5230        loop {
5231            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5232            let before = self.pos;
5233            self.consume_optional_deferrable_clauses()?;
5234            if self.pos != before {
5235                continue;
5236            }
5237            // ON DELETE / ON UPDATE.
5238            if !matches!(self.peek(), Token::On) {
5239                break;
5240            }
5241            self.advance();
5242            let which = self.advance();
5243            let action = self.parse_fk_action()?;
5244            match which {
5245                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5246                    if seen_on_delete {
5247                        return Err(self.err("ON DELETE specified twice".into()));
5248                    }
5249                    seen_on_delete = true;
5250                    on_delete = action;
5251                }
5252                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5253                    if seen_on_update {
5254                        return Err(self.err("ON UPDATE specified twice".into()));
5255                    }
5256                    seen_on_update = true;
5257                    on_update = action;
5258                }
5259                other => {
5260                    return Err(
5261                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5262                    );
5263                }
5264            }
5265        }
5266        Ok((parent_table, parent_columns, on_delete, on_update))
5267    }
5268
5269    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5270    /// NO ACTION`.
5271    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5272        match self.advance() {
5273            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5274            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5275            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5276                Token::Null => Ok(FkAction::SetNull),
5277                Token::Default => Ok(FkAction::SetDefault),
5278                other => Err(self.err(format!(
5279                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5280                ))),
5281            },
5282            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5283                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5284                other => Err(self.err(format!(
5285                    "expected ACTION after NO in FK action, got {other:?}"
5286                ))),
5287            },
5288            other => Err(self.err(format!(
5289                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5290            ))),
5291        }
5292    }
5293
5294    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5295    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5296    fn consume_if_not_exists(&mut self) -> bool {
5297        // `IF` arrives as a bare Ident (we don't reserve it because it
5298        // also appears mid-expression in PG, though we don't support
5299        // those forms yet).
5300        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5301        if !looks_like_if {
5302            return false;
5303        }
5304        // Peek one ahead before committing: only consume IF when it's
5305        // actually `IF NOT EXISTS`.
5306        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5307            return false;
5308        }
5309        if !matches!(
5310            self.tokens.get(self.pos + 2),
5311            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5312        ) {
5313            return false;
5314        }
5315        self.advance(); // IF
5316        self.advance(); // NOT
5317        self.advance(); // EXISTS
5318        true
5319    }
5320
5321    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5322    /// Consumes IF EXISTS as a pair; returns false otherwise
5323    /// without consuming any tokens.
5324    fn consume_if_exists(&mut self) -> bool {
5325        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5326        if !looks_like_if {
5327            return false;
5328        }
5329        if !matches!(
5330            self.tokens.get(self.pos + 1),
5331            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5332        ) {
5333            return false;
5334        }
5335        self.advance(); // IF
5336        self.advance(); // EXISTS
5337        true
5338    }
5339
5340    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5341    /// qualifiers after an index column ref. ASC / DESC are
5342    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5343    /// We accept and discard them since single-column BTree
5344    /// stores rows in natural key order today.
5345    fn consume_optional_index_column_qualifiers(&mut self) {
5346        loop {
5347            match self.peek() {
5348                Token::Asc | Token::Desc => {
5349                    self.advance();
5350                }
5351                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5352                    let look = self.tokens.get(self.pos + 1);
5353                    if matches!(
5354                        look,
5355                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5356                            || k.eq_ignore_ascii_case("last")
5357                    ) {
5358                        self.advance();
5359                        self.advance();
5360                    } else {
5361                        break;
5362                    }
5363                }
5364                _ => break,
5365            }
5366        }
5367    }
5368
5369    fn parse_create_index_stmt_after_create(
5370        &mut self,
5371        is_unique: bool,
5372    ) -> Result<Statement, ParseError> {
5373        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
5374        debug_assert!(matches!(self.peek(), Token::Index));
5375        self.advance();
5376        let if_not_exists = self.consume_if_not_exists();
5377        let name = self.expect_ident_like()?;
5378        if !matches!(self.peek(), Token::On) {
5379            return Err(self.err(format!(
5380                "expected ON after CREATE INDEX <name>, got {:?}",
5381                self.peek()
5382            )));
5383        }
5384        self.advance();
5385        let table = self.expect_ident_like()?;
5386        // Optional `USING <method>` — only recognised method in v2.0 is
5387        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
5388        // ident `using` (we don't promote it to a reserved keyword
5389        // because it isn't reserved anywhere else in our SQL surface).
5390        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5391            self.advance();
5392            let m = self.expect_ident_like()?;
5393            match m.to_ascii_lowercase().as_str() {
5394                "hnsw" => IndexMethod::Hnsw,
5395                "btree" => IndexMethod::BTree,
5396                "brin" => IndexMethod::Brin,
5397                // v7.12.3 — real GIN inverted index over `tsvector`.
5398                // v7.9.26b's `USING gin` → BTree silent fallback is
5399                // gone; the engine validates that the indexed column
5400                // is `tsvector` at CREATE INDEX time.
5401                "gin" => IndexMethod::Gin,
5402                // v7.9.26b — PG `pg_dump` emits `USING gist` /
5403                // `USING spgist` / `USING hash` for their built-in
5404                // AMs that SPG doesn't have a matching
5405                // implementation for; degrade to BTree on the
5406                // leading column so the schema loads + the index
5407                // catalogue stays consistent. Operator pays the
5408                // planner cost only for the queries that would have
5409                // used the specialised AM.
5410                "gist" | "spgist" | "hash" => IndexMethod::BTree,
5411                // v7.11.3 — pgvector ships both `ivfflat` and
5412                // `hnsw`. Customers shouldn't have to choose
5413                // their on-disk index method based on what SPG
5414                // implements; accept `ivfflat` as a synonym for
5415                // `hnsw` so PG schemas using either method drop
5416                // in. The vector distance op (`<->` / `<#>` /
5417                // `<=>`) at query time still picks the metric.
5418                "ivfflat" => IndexMethod::Hnsw,
5419                other => {
5420                    return Err(self.err(alloc::format!(
5421                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
5422                    )));
5423                }
5424            }
5425        } else {
5426            IndexMethod::BTree
5427        };
5428        if !matches!(self.peek(), Token::LParen) {
5429            return Err(self.err(format!(
5430                "expected '(' before indexed column, got {:?}",
5431                self.peek()
5432            )));
5433        }
5434        self.advance();
5435        // v6.8.2 — accept either a bare column ident (legacy) or
5436        // an expression `fn(col, …)` for expression indexes.
5437        // Distinguish by peeking the token *after* the current
5438        // ident: `ident )` is the legacy column-only path;
5439        // anything else triggers the Pratt expression parser.
5440        // (`advance()` uses `mem::replace` to nil out the current
5441        // slot, so we can't save+rewind cleanly — peek-ahead via
5442        // direct index avoids the mutation.)
5443        let mut opclass: Option<String> = None;
5444        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
5445            // Single column with `)` immediately after — fast path.
5446            // v7.9.29 — also: bare column followed by `,` (the
5447            // multi-column form `(a, b, c)`). Without this branch
5448            // the leading ident gets pulled into `parse_expr`
5449            // which then sets `expression = Some(Column(a))` and
5450            // breaks Display round-trip on the multi-column shape.
5451            Token::Ident(s) | Token::QuotedIdent(s)
5452                if matches!(
5453                    self.tokens.get(self.pos + 1),
5454                    Some(Token::RParen | Token::Comma)
5455                ) =>
5456            {
5457                self.advance();
5458                (s, None)
5459            }
5460            // v7.9.22 — single column followed by a pgvector
5461            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
5462            // v7.15.0 — capture the opclass instead of discarding
5463            // it so the engine can dispatch (e.g. `gin_trgm_ops`
5464            // → real trigram-shingle GIN over a TEXT column).
5465            // Vector/HNSW opclasses still take their distance
5466            // metric from the query operator (`<->` / `<#>` /
5467            // `<=>`), so for those callers the opclass stays
5468            // informational.
5469            Token::Ident(s) | Token::QuotedIdent(s)
5470                if matches!(
5471                    self.tokens.get(self.pos + 1),
5472                    Some(Token::Ident(op) | Token::QuotedIdent(op))
5473                        if is_vector_opclass_name(op)
5474                ) =>
5475            {
5476                self.advance(); // column name
5477                // Capture the opclass token, lower-cased for
5478                // case-insensitive engine dispatch.
5479                let op_tok = self.advance();
5480                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5481                    opclass = Some(op.to_ascii_lowercase());
5482                }
5483                (s, None)
5484            }
5485            Token::Ident(_) | Token::QuotedIdent(_) => {
5486                let key_expr = self.parse_expr(0)?;
5487                let primary = extract_first_column(&key_expr).ok_or_else(|| {
5488                    self.err("expression index key must reference at least one column".into())
5489                })?;
5490                (primary, Some(key_expr))
5491            }
5492            other => {
5493                return Err(self.err(format!(
5494                    "expected column ident or expression, got {other:?}"
5495                )));
5496            }
5497        };
5498        // v7.9.14 — accept extra comma-separated columns inside
5499        // the index key parens (`CREATE INDEX … (a, b, c)`).
5500        // mailrs F2. Each extra column may carry an optional
5501        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
5502        // — parsed and discarded; SPG doesn't honour direction
5503        // on a BTree index today (column ordering is intrinsic
5504        // to the storage). v7.10 will widen to genuine composite
5505        // index keys.
5506        let mut extra_columns: Vec<String> = Vec::new();
5507        // The leading column may also have ASC/DESC after it.
5508        self.consume_optional_index_column_qualifiers();
5509        while matches!(self.peek(), Token::Comma) {
5510            self.advance();
5511            let extra = self.expect_ident_like()?;
5512            self.consume_optional_index_column_qualifiers();
5513            extra_columns.push(extra);
5514        }
5515        if !matches!(self.peek(), Token::RParen) {
5516            return Err(self.err(format!(
5517                "expected ')' after indexed column / expression, got {:?}",
5518                self.peek()
5519            )));
5520        }
5521        self.advance();
5522        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
5523        // index-only-scan annotation. Bare ident (not a reserved
5524        // keyword) so we test by case-insensitive string match.
5525        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
5526        {
5527            self.advance();
5528            if !matches!(self.peek(), Token::LParen) {
5529                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
5530            }
5531            self.advance();
5532            let mut cols = Vec::new();
5533            loop {
5534                cols.push(self.expect_ident_like()?);
5535                match self.peek() {
5536                    Token::Comma => {
5537                        self.advance();
5538                    }
5539                    Token::RParen => {
5540                        self.advance();
5541                        break;
5542                    }
5543                    other => {
5544                        return Err(self.err(format!(
5545                            "expected ',' or ')' in INCLUDE list, got {other:?}"
5546                        )));
5547                    }
5548                }
5549            }
5550            cols
5551        } else {
5552            Vec::new()
5553        };
5554        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
5555        // storage parameters. pgvector emits `WITH (lists = N)` for
5556        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
5557        // SPG's HNSW picks its own parameters today (tunable via
5558        // env vars), so the WITH clause is informational and dropped.
5559        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
5560            self.advance();
5561            if !matches!(self.peek(), Token::LParen) {
5562                return Err(self.err(format!(
5563                    "expected '(' after WITH in CREATE INDEX, got {:?}",
5564                    self.peek()
5565                )));
5566            }
5567            self.advance();
5568            loop {
5569                if matches!(self.peek(), Token::RParen) {
5570                    self.advance();
5571                    break;
5572                }
5573                // Drain `key = value` or bare `key` tokens.
5574                let _ = self.advance(); // key
5575                if matches!(self.peek(), Token::Eq) {
5576                    self.advance();
5577                    let _ = self.advance(); // value (int / string / ident)
5578                }
5579                match self.peek() {
5580                    Token::Comma => {
5581                        self.advance();
5582                    }
5583                    Token::RParen => {
5584                        self.advance();
5585                        break;
5586                    }
5587                    other => {
5588                        return Err(self.err(format!(
5589                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
5590                        )));
5591                    }
5592                }
5593            }
5594        }
5595        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
5596        let partial_predicate = if matches!(self.peek(), Token::Where) {
5597            self.advance();
5598            Some(self.parse_expr(0)?)
5599        } else {
5600            None
5601        };
5602        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
5603        // sense: uniqueness over an ANN structure has no clean
5604        // semantics. Reject early. (BRIN UNIQUE is similarly
5605        // meaningless — block both.)
5606        if is_unique && !matches!(method, IndexMethod::BTree) {
5607            return Err(self.err(alloc::format!(
5608                "UNIQUE is only supported on BTree indexes, got USING {:?}",
5609                method
5610            )));
5611        }
5612        Ok(Statement::CreateIndex(CreateIndexStatement {
5613            name,
5614            table,
5615            column,
5616            method,
5617            if_not_exists,
5618            included_columns,
5619            partial_predicate,
5620            extra_columns: extra_columns.clone(),
5621            expression,
5622            is_unique,
5623            opclass,
5624        }))
5625    }
5626
5627    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
5628    /// column-level `REFERENCES ...` clause. The trailing FK is
5629    /// normalised into table-level shape (single-element columns +
5630    /// parent_columns) so the engine sees one uniform constraint list.
5631    fn parse_column_def_with_fk(
5632        &mut self,
5633    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
5634        let col = self.parse_column_def()?;
5635        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
5636        let inline_references = matches!(
5637            self.peek(),
5638            Token::Ident(s) if s.eq_ignore_ascii_case("references")
5639        );
5640        if !inline_references {
5641            return Ok((col, None));
5642        }
5643        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
5644        let fk = ForeignKeyConstraint {
5645            name: None,
5646            columns: vec![col.name.clone()],
5647            parent_table,
5648            parent_columns,
5649            on_delete,
5650            on_update,
5651        };
5652        Ok((col, Some(fk)))
5653    }
5654
5655    /// v7.13.0 — parse a column type (consuming the type ident and
5656    /// any trailing parameters / `[]`), without surrounding column
5657    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
5658    /// Returns the resolved `ColumnTypeName` plus implied
5659    /// `(auto_increment, not_null)` flags from PG SERIAL family
5660    /// shorthands — callers that don't expect those (ALTER COLUMN
5661    /// TYPE) can discard them.
5662    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
5663        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
5664        Ok(ty)
5665    }
5666
5667    #[allow(clippy::type_complexity)]
5668    fn parse_type_with_implied_flags(
5669        &mut self,
5670    ) -> Result<
5671        (
5672            ColumnTypeName,
5673            bool,
5674            bool,
5675            Option<String>,
5676            Collation,
5677            bool,
5678            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
5679            // list captured at type-parse time. None for all
5680            // non-ENUM types.
5681            Option<Vec<String>>,
5682            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
5683            // list. Distinct from ENUM (subset semantics).
5684            Option<Vec<String>>,
5685        ),
5686        ParseError,
5687    > {
5688        let ty_ident = match self.advance() {
5689            Token::Ident(s) => s,
5690            other => {
5691                return Err(ParseError {
5692                    message: format!("expected column type, got {other:?}"),
5693                    token_pos: self.pos.saturating_sub(1),
5694                });
5695            }
5696        };
5697        let mut implied_auto_increment = false;
5698        let mut implied_not_null = false;
5699        let mut user_type_ref: Option<String> = None;
5700        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
5701        // value list, captured here and bubbled up through the
5702        // ColumnDef so the engine can attach it to the column
5703        // schema (and validate INSERT cells against it).
5704        let mut inline_enum_variants: Option<Vec<String>> = None;
5705        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
5706        let mut inline_set_variants: Option<Vec<String>> = None;
5707        let mut ty = match ty_ident.as_str() {
5708            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
5709            "smallserial" | "serial2" => {
5710                implied_auto_increment = true;
5711                implied_not_null = true;
5712                ColumnTypeName::SmallInt
5713            }
5714            "serial" | "serial4" => {
5715                implied_auto_increment = true;
5716                implied_not_null = true;
5717                ColumnTypeName::Int
5718            }
5719            "bigserial" | "serial8" => {
5720                implied_auto_increment = true;
5721                implied_not_null = true;
5722                ColumnTypeName::BigInt
5723            }
5724            // MySQL flavours we accept by aliasing to the closest SPG
5725            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
5726            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
5727            // 24-bit) → INT. UNSIGNED modifiers are consumed below
5728            // without semantic effect.
5729            "smallint" => {
5730                // v7.14.0 — MySQL display-width on integers
5731                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
5732                // parenthesised number is purely cosmetic — it
5733                // doesn't change storage. Accept + discard.
5734                self.consume_optional_paren_size();
5735                ColumnTypeName::SmallInt
5736            }
5737            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
5738            // canonical encoding for BOOLEAN. Every MySQL driver
5739            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
5740            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
5741            // 4.3 SPG classified TINYINT(1) as SmallInt, which
5742            // gave the customer i16-shaped values where the app
5743            // expected bool — a Tier-A silent type drift on
5744            // mysqldump restores. Now: `TINYINT(1)` → Bool;
5745            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
5746            // stay SmallInt (the legacy width-agnostic path).
5747            "tinyint" => {
5748                let width = self.peek_optional_paren_size_value();
5749                self.consume_optional_paren_size();
5750                if width == Some(1) {
5751                    ColumnTypeName::Bool
5752                } else {
5753                    ColumnTypeName::SmallInt
5754                }
5755            }
5756            "int" | "integer" | "mediumint" => {
5757                self.consume_optional_paren_size();
5758                ColumnTypeName::Int
5759            }
5760            "bigint" => {
5761                self.consume_optional_paren_size();
5762                ColumnTypeName::BigInt
5763            }
5764            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
5765            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
5766            // (mailrs round-5 G6). Consume the optional `PRECISION`
5767            // tail when the type keyword was `double` / `DOUBLE`.
5768            "float" | "double" | "real" => {
5769                if ty_ident.eq_ignore_ascii_case("double")
5770                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
5771                {
5772                    self.advance();
5773                }
5774                ColumnTypeName::Float
5775            }
5776            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
5777            "float4" | "float8" => ColumnTypeName::Float,
5778            "text" => ColumnTypeName::Text,
5779            "bool" | "boolean" => ColumnTypeName::Bool,
5780            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
5781            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
5782            "vector" => {
5783                let dim = self.parse_paren_size("VECTOR")?;
5784                let encoding = self.parse_optional_vector_encoding()?;
5785                ColumnTypeName::Vector { dim, encoding }
5786            }
5787            "numeric" => {
5788                let (precision, scale) = self.parse_optional_numeric_params()?;
5789                ColumnTypeName::Numeric(precision, scale)
5790            }
5791            "date" => ColumnTypeName::Date,
5792            // MySQL's `DATETIME` is the same domain as standard
5793            // `TIMESTAMP` — accept both spellings.
5794            "timestamp" | "datetime" => {
5795                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
5796                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
5797                // the full form. SPG canonicalises:
5798                //   - WITH TIME ZONE    → Timestamptz
5799                //   - WITHOUT TIME ZONE → Timestamp
5800                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
5801                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
5802                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
5803                {
5804                    self.advance(); // WITH
5805                    self.advance(); // TIME
5806                    self.advance(); // ZONE
5807                    ColumnTypeName::Timestamptz
5808                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
5809                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
5810                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
5811                {
5812                    self.advance(); // WITHOUT
5813                    self.advance(); // TIME
5814                    self.advance(); // ZONE
5815                    ColumnTypeName::Timestamp
5816                } else {
5817                    // Optional `(precision)` parenthesised modifier
5818                    // (PG fractional seconds precision). SPG stores
5819                    // µs always; accept + discard.
5820                    self.consume_optional_paren_size();
5821                    ColumnTypeName::Timestamp
5822                }
5823            }
5824            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
5825            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
5826            // only PG-wire OID differs.
5827            "timestamptz" => ColumnTypeName::Timestamptz,
5828            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
5829            // validation. We accept the JSONB spelling too because
5830            // most PG clients default to it; SPG doesn't distinguish
5831            // the two (no path-operator perf advantage to model).
5832            "json" => ColumnTypeName::Json,
5833            "jsonb" => ColumnTypeName::Jsonb,
5834            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
5835            // surface here. Same storage shape; mapping happens at
5836            // the engine side via the ColumnTypeName → DataType
5837            // resolver. Literal forms are handled at coerce_value
5838            // time so the lexer stays untouched.
5839            "bytea" | "bytes" => ColumnTypeName::Bytes,
5840            // v7.17.0 Phase 7 — PG network address types
5841            // (`inet`, `cidr`, `macaddr`). pg_dump / Django ORM
5842            // emit these for IP-address columns; pre-7 SPG
5843            // errored with "unknown type", breaking schema
5844            // restores. Accept as Text-backed (no new storage
5845            // shape): the customer's data round-trips, and the
5846            // host() / network() helpers added in the same
5847            // phase work textually. Containment operators
5848            // `<<=` / `>>=` are out of v7.17 scope.
5849            "inet" | "cidr" | "macaddr" => ColumnTypeName::Text,
5850            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
5851            // The actual `to_tsvector` / `@@` / `ts_rank` surface
5852            // arrives in v7.12.1+; the type itself loads here so
5853            // mailrs's `scripts/init-schema.sql` runs unmodified.
5854            "tsvector" => ColumnTypeName::TsVector,
5855            "tsquery" => ColumnTypeName::TsQuery,
5856            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
5857            // surface for Django / Rails / Hibernate's default
5858            // PK pattern.
5859            "uuid" => ColumnTypeName::Uuid,
5860            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
5861            // i64 microseconds since 00:00:00. Wire OID 1083.
5862            "time" => ColumnTypeName::Time,
5863            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
5864            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
5865            "year" => ColumnTypeName::Year,
5866            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
5867            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
5868            "timetz" => ColumnTypeName::TimeTz,
5869            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
5870            // Wire OID 790.
5871            "money" => ColumnTypeName::Money,
5872            // v7.17.0 Phase 3.P0-38 — PG range types.
5873            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
5874            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
5875            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
5876            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
5877            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
5878            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
5879            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
5880            "hstore" => ColumnTypeName::Hstore,
5881            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
5882            // `ENUM('a','b','c')`. Storage is TEXT; the value
5883            // list lands on `inline_enum_variants` for the
5884            // engine to validate INSERT cells against. Empty
5885            // value list is a parse error (matches MySQL).
5886            "enum" => {
5887                // Expect the opening `(`.
5888                if !matches!(self.peek(), Token::LParen) {
5889                    return Err(self.err(alloc::format!(
5890                        "expected '(' after ENUM, got {:?}",
5891                        self.peek()
5892                    )));
5893                }
5894                self.advance();
5895                let mut variants: Vec<String> = Vec::new();
5896                loop {
5897                    match self.advance() {
5898                        Token::String(s) => variants.push(s),
5899                        other => {
5900                            return Err(self.err(alloc::format!(
5901                                "ENUM(...) expects string literal variants, got {other:?}"
5902                            )));
5903                        }
5904                    }
5905                    match self.peek() {
5906                        Token::Comma => {
5907                            self.advance();
5908                            continue;
5909                        }
5910                        Token::RParen => {
5911                            self.advance();
5912                            break;
5913                        }
5914                        other => {
5915                            return Err(self.err(alloc::format!(
5916                                "expected ',' or ')' in ENUM(...), got {other:?}"
5917                            )));
5918                        }
5919                    }
5920                }
5921                if variants.is_empty() {
5922                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
5923                }
5924                inline_enum_variants = Some(variants);
5925                // Storage is plain TEXT; the variant list lives on
5926                // the ColumnSchema side.
5927                ColumnTypeName::Text
5928            }
5929            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
5930            // `SET('a','b','c')`. Same parse shape as ENUM;
5931            // semantics differ (subset rather than pick-one).
5932            "set" => {
5933                if !matches!(self.peek(), Token::LParen) {
5934                    return Err(self.err(alloc::format!(
5935                        "expected '(' after SET, got {:?}",
5936                        self.peek()
5937                    )));
5938                }
5939                self.advance();
5940                let mut variants: Vec<String> = Vec::new();
5941                loop {
5942                    match self.advance() {
5943                        Token::String(s) => variants.push(s),
5944                        other => {
5945                            return Err(self.err(alloc::format!(
5946                                "SET(...) expects string literal variants, got {other:?}"
5947                            )));
5948                        }
5949                    }
5950                    match self.peek() {
5951                        Token::Comma => {
5952                            self.advance();
5953                            continue;
5954                        }
5955                        Token::RParen => {
5956                            self.advance();
5957                            break;
5958                        }
5959                        other => {
5960                            return Err(self.err(alloc::format!(
5961                                "expected ',' or ')' in SET(...), got {other:?}"
5962                            )));
5963                        }
5964                    }
5965                }
5966                if variants.is_empty() {
5967                    return Err(self.err("SET(...) must declare at least one variant".into()));
5968                }
5969                inline_set_variants = Some(variants);
5970                ColumnTypeName::Text
5971            }
5972            _other => {
5973                // v7.17.0 Phase 1.4 — unknown ident → defer
5974                // resolution to the engine. Stored as Text in
5975                // ColumnTypeName + the original name carried as
5976                // `user_type_ref` so CREATE TABLE can look up
5977                // user-defined enum / domain types.
5978                user_type_ref = Some(ty_ident.clone());
5979                ColumnTypeName::Text
5980            }
5981        };
5982        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
5983        // right after the type keyword. Pre-4.4 SPG consumed +
5984        // discarded the keyword, leaving a customer column
5985        // declared `id INT UNSIGNED NOT NULL` silently accepting
5986        // negative values — a Tier-A correctness drift where
5987        // application invariants (auto-increment-IDs never
5988        // negative) silently broke on cutover. Now: capture as
5989        // a column flag, persist on the schema, enforce at
5990        // INSERT / UPDATE time.
5991        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
5992        {
5993            self.advance();
5994            true
5995        } else {
5996            false
5997        };
5998        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
5999        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6000        // stores text as UTF-8 always so CHARACTER SET is still a
6001        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6002        // name: it gets classified into a `Collation` variant the
6003        // engine consults at WHERE-eval time. PG `default` /
6004        // `pg_catalog.default` / `C` / `POSIX` collations all
6005        // resolve to `Binary` (the prior behaviour); `_ci` /
6006        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6007        // The schema-qualifier form (`pg_catalog.default`) lexes
6008        // as `Ident '.' Ident` — peek for the `.` and consume both
6009        // halves so it's treated as one collation name. PG's
6010        // `IDENT.IDENT` collation form (which can appear here) is
6011        // resolved by Collation::from_collation_name on the bare
6012        // identifier after the dot.
6013        let mut collation = Collation::Binary;
6014        loop {
6015            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6016                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6017            {
6018                self.advance(); // CHARACTER
6019                self.advance(); // SET
6020                if matches!(
6021                    self.peek(),
6022                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6023                ) {
6024                    self.advance();
6025                }
6026                continue;
6027            }
6028            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6029                self.advance(); // COLLATE
6030                // Accept Ident / QuotedIdent / String AND the
6031                // keyword-tokenised `Default` (PG `pg_catalog.default`
6032                // and bare `DEFAULT` collation names — `default` is a
6033                // reserved word so the lexer hands back Token::Default
6034                // not Token::Ident).
6035                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6036                    match this.peek().clone() {
6037                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6038                            this.advance();
6039                            Some(s)
6040                        }
6041                        Token::Default => {
6042                            this.advance();
6043                            Some(alloc::string::String::from("default"))
6044                        }
6045                        _ => None,
6046                    }
6047                };
6048                let raw = if let Some(head) = read_collation_atom(self) {
6049                    // Schema-qualified PG form: `pg_catalog.default`.
6050                    if matches!(self.peek(), Token::Dot) {
6051                        self.advance();
6052                        let tail = read_collation_atom(self).unwrap_or_default();
6053                        alloc::format!("{head}.{tail}")
6054                    } else {
6055                        head
6056                    }
6057                } else {
6058                    alloc::string::String::new()
6059                };
6060                if !raw.is_empty() {
6061                    let parsed = Collation::from_collation_name(&raw);
6062                    // Last COLLATE clause wins, but `Binary` from a
6063                    // bare keyword like `default` should not
6064                    // silently downgrade a stronger one set earlier
6065                    // on the same column. v7.17 only ships one
6066                    // non-Binary variant so a simple OR is enough.
6067                    if parsed != Collation::Binary {
6068                        collation = parsed;
6069                    }
6070                }
6071                continue;
6072            }
6073            break;
6074        }
6075        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6076        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6077        // so we reject other base types here. mailrs uses TEXT[]
6078        // for labels / addresses / message-on-thread.
6079        if matches!(self.peek(), Token::LBracket) {
6080            self.advance();
6081            if !matches!(self.peek(), Token::RBracket) {
6082                return Err(self.err(alloc::format!(
6083                    "TEXT[] takes no dimension; got {:?}",
6084                    self.peek()
6085                )));
6086            }
6087            self.advance();
6088            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6089            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6090            // still error here.
6091            ty = match ty {
6092                ColumnTypeName::Text => ColumnTypeName::TextArray,
6093                ColumnTypeName::Int => ColumnTypeName::IntArray,
6094                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6095                other => {
6096                    return Err(self.err(alloc::format!(
6097                        "v7.11 supports TEXT[] / INT[] / BIGINT[] only; got {other:?}[]"
6098                    )));
6099                }
6100            };
6101            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6102            // for INT/TEXT/BIGINT. Anything else is an error.
6103            if matches!(self.peek(), Token::LBracket) {
6104                self.advance();
6105                if !matches!(self.peek(), Token::RBracket) {
6106                    return Err(self.err(alloc::format!(
6107                        "TYPE[][] second dimension takes no size; got {:?}",
6108                        self.peek()
6109                    )));
6110                }
6111                self.advance();
6112                ty = match ty {
6113                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6114                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6115                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6116                    other => {
6117                        return Err(self.err(alloc::format!(
6118                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6119                             TEXT[][] only; got {other:?}"
6120                        )));
6121                    }
6122                };
6123            }
6124        }
6125        Ok((
6126            ty,
6127            implied_auto_increment,
6128            implied_not_null,
6129            user_type_ref,
6130            collation,
6131            is_unsigned,
6132            inline_enum_variants,
6133            inline_set_variants,
6134        ))
6135    }
6136
6137    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6138        let name = self.expect_ident_like()?;
6139        let (
6140            ty,
6141            implied_auto_increment,
6142            implied_not_null,
6143            user_type_ref,
6144            collation,
6145            is_unsigned,
6146            inline_enum_variants,
6147            inline_set_variants,
6148        ) = self.parse_type_with_implied_flags()?;
6149        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6150        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6151        // each at most once.
6152        let mut default: Option<Expr> = None;
6153        let mut nullable = !implied_not_null;
6154        let mut nullability_seen = implied_not_null;
6155        let mut auto_increment = implied_auto_increment;
6156        let mut is_primary_key = false;
6157        let mut is_unique = false;
6158        let mut check: Option<Expr> = None;
6159        let mut on_update_runtime: Option<Expr> = None;
6160        loop {
6161            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
6162            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
6163            // is accepted today. The "ON" token is an Ident
6164            // (not reserved) — peek before consuming.
6165            if matches!(self.peek(), Token::On)
6166                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
6167            {
6168                self.advance(); // ON
6169                self.advance(); // update
6170                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
6171                let next = self.peek().clone();
6172                match next {
6173                    Token::Ident(s) | Token::QuotedIdent(s)
6174                        if s.eq_ignore_ascii_case("current_timestamp") =>
6175                    {
6176                        self.advance();
6177                        // Optional `(N)` precision.
6178                        if matches!(self.peek(), Token::LParen) {
6179                            self.advance();
6180                            if !matches!(self.peek(), Token::Integer(_)) {
6181                                return Err(self.err(alloc::format!(
6182                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
6183                                    self.peek()
6184                                )));
6185                            }
6186                            self.advance();
6187                            if !matches!(self.peek(), Token::RParen) {
6188                                return Err(self.err(alloc::format!(
6189                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
6190                                    self.peek()
6191                                )));
6192                            }
6193                            self.advance();
6194                        }
6195                        on_update_runtime = Some(Expr::FunctionCall {
6196                            name: "now".into(),
6197                            args: Vec::new(),
6198                        });
6199                        continue;
6200                    }
6201                    other => {
6202                        return Err(self.err(alloc::format!(
6203                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
6204                        )));
6205                    }
6206                }
6207            }
6208            if matches!(self.peek(), Token::Default) {
6209                if default.is_some() {
6210                    return Err(self.err("DEFAULT specified twice".into()));
6211                }
6212                self.advance();
6213                default = Some(self.parse_expr(0)?);
6214                continue;
6215            }
6216            if matches!(self.peek(), Token::Not) {
6217                if nullability_seen {
6218                    return Err(self.err("NOT NULL specified twice".into()));
6219                }
6220                self.advance();
6221                if !matches!(self.peek(), Token::Null) {
6222                    return Err(self.err(format!(
6223                        "expected NULL after NOT in column def, got {:?}",
6224                        self.peek()
6225                    )));
6226                }
6227                self.advance();
6228                nullable = false;
6229                nullability_seen = true;
6230                continue;
6231            }
6232            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
6233            // "this column is nullable" marker (the default in
6234            // standard SQL anyway). mysqldump emits it routinely
6235            // (`col TYPE NULL DEFAULT NULL` for nullable
6236            // timestamps etc). Accept + no-op.
6237            if matches!(self.peek(), Token::Null) {
6238                if nullability_seen && !nullable {
6239                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
6240                }
6241                self.advance();
6242                nullable = true;
6243                nullability_seen = true;
6244                continue;
6245            }
6246            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
6247            // arrives as a bare Ident. Match either, case-insensitive.
6248            if let Token::Ident(s) = self.peek()
6249                && (s.eq_ignore_ascii_case("auto_increment")
6250                    || s.eq_ignore_ascii_case("autoincrement"))
6251            {
6252                if auto_increment {
6253                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
6254                }
6255                self.advance();
6256                auto_increment = true;
6257                continue;
6258            }
6259            // v7.9.13 — inline `PRIMARY KEY` column constraint
6260            // (mailrs F1). Implies `NOT NULL`. The engine creates
6261            // a BTree index for the PK column at CREATE TABLE time
6262            // so FK parent-side index lookups resolve.
6263            if let Token::Ident(s) = self.peek()
6264                && s.eq_ignore_ascii_case("primary")
6265            {
6266                if is_primary_key {
6267                    return Err(self.err("PRIMARY KEY specified twice".into()));
6268                }
6269                // Peek-ahead for the required `KEY` token.
6270                let next = self.tokens.get(self.pos + 1);
6271                let next_is_key = matches!(
6272                    next,
6273                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
6274                );
6275                if !next_is_key {
6276                    return Err(self.err(format!(
6277                        "expected KEY after PRIMARY in column def, got {:?}",
6278                        next
6279                    )));
6280                }
6281                self.advance(); // PRIMARY
6282                self.advance(); // KEY
6283                is_primary_key = true;
6284                if nullability_seen && nullable {
6285                    return Err(self.err(
6286                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
6287                    ));
6288                }
6289                nullable = false;
6290                nullability_seen = true;
6291                continue;
6292            }
6293            // v7.13.0 — inline `UNIQUE` column constraint
6294            // (mailrs round-5 G2). Fold into a single-column
6295            // table-level UNIQUE at CREATE TABLE post-process time.
6296            if let Token::Ident(s) = self.peek()
6297                && s.eq_ignore_ascii_case("unique")
6298            {
6299                if is_unique {
6300                    return Err(self.err("UNIQUE specified twice".into()));
6301                }
6302                self.advance();
6303                is_unique = true;
6304                continue;
6305            }
6306            // v7.13.0 — inline `CHECK (<expr>)` column constraint
6307            // (mailrs round-5 G3). PG semantics: column-level
6308            // CHECK is equivalent to a table-level CHECK. Multiple
6309            // inline CHECKs on the same column AND together.
6310            if let Token::Ident(s) = self.peek()
6311                && s.eq_ignore_ascii_case("check")
6312            {
6313                self.advance();
6314                if !matches!(self.peek(), Token::LParen) {
6315                    return Err(self.err(alloc::format!(
6316                        "expected '(' after CHECK in column def, got {:?}",
6317                        self.peek()
6318                    )));
6319                }
6320                self.advance();
6321                let pred = self.parse_expr(0)?;
6322                if !matches!(self.peek(), Token::RParen) {
6323                    return Err(self.err(alloc::format!(
6324                        "expected ')' to close CHECK predicate, got {:?}",
6325                        self.peek()
6326                    )));
6327                }
6328                self.advance();
6329                check = Some(match check.take() {
6330                    Some(prev) => Expr::Binary {
6331                        op: BinOp::And,
6332                        lhs: Box::new(prev),
6333                        rhs: Box::new(pred),
6334                    },
6335                    None => pred,
6336                });
6337                continue;
6338            }
6339            break;
6340        }
6341        Ok(ColumnDef {
6342            name,
6343            ty,
6344            nullable,
6345            default,
6346            auto_increment,
6347            is_primary_key,
6348            is_unique,
6349            check,
6350            user_type_ref,
6351            on_update_runtime,
6352            collation,
6353            is_unsigned,
6354            inline_enum_variants,
6355            inline_set_variants,
6356        })
6357    }
6358
6359    /// `NUMERIC` may appear without parameters, with one (precision
6360    /// only, scale=0), or with both. Returns `(precision, scale)` with
6361    /// 0 = unspecified for the bare form.
6362    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
6363        if !matches!(self.peek(), Token::LParen) {
6364            // Bare `NUMERIC` — PG treats this as "unlimited precision";
6365            // we surface it as precision=0 to mean "unconstrained" so
6366            // the engine doesn't need a separate variant.
6367            return Ok((0, 0));
6368        }
6369        self.advance();
6370        let precision = match self.advance() {
6371            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
6372            other => {
6373                return Err(ParseError {
6374                    message: format!(
6375                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
6376                    ),
6377                    token_pos: self.pos.saturating_sub(1),
6378                });
6379            }
6380        };
6381        let scale = if matches!(self.peek(), Token::Comma) {
6382            self.advance();
6383            match self.advance() {
6384                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
6385                    u8::try_from(n).expect("range-checked")
6386                }
6387                other => {
6388                    return Err(ParseError {
6389                        message: format!(
6390                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
6391                        ),
6392                        token_pos: self.pos.saturating_sub(1),
6393                    });
6394                }
6395            }
6396        } else {
6397            0
6398        };
6399        if !matches!(self.peek(), Token::RParen) {
6400            return Err(self.err(format!(
6401                "expected ')' to close NUMERIC params, got {:?}",
6402                self.peek()
6403            )));
6404        }
6405        self.advance();
6406        Ok((precision, scale))
6407    }
6408
6409    /// Parse `(N)` where `N` is a positive integer literal — used by the
6410    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
6411    /// for the error message.
6412    /// v6.0.1: parse the optional `USING <encoding>` clause that
6413    /// follows `VECTOR(N)` in a column definition. Missing clause
6414    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
6415    /// ident → `ParseError` listing the encodings recognised today.
6416    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
6417        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6418            return Ok(VecEncoding::F32);
6419        }
6420        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
6421        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
6422        // consume the token when the very next token is a known
6423        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
6424        // `USING` for the caller — it's the rewrite-expression form.
6425        let n1 = self.tokens.get(self.pos + 1);
6426        let next_is_encoding = matches!(
6427            n1,
6428            Some(Token::Ident(s))
6429                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
6430        );
6431        if !next_is_encoding {
6432            return Ok(VecEncoding::F32);
6433        }
6434        self.advance();
6435        let enc_ident = match self.advance() {
6436            Token::Ident(s) => s,
6437            other => {
6438                return Err(self.err(format!(
6439                    "expected vector encoding after USING, got {other:?}"
6440                )));
6441            }
6442        };
6443        match enc_ident.to_ascii_lowercase().as_str() {
6444            "sq8" => Ok(VecEncoding::Sq8),
6445            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
6446            // binary16 per-element storage.
6447            "half" => Ok(VecEncoding::F16),
6448            other => Err(self.err(format!(
6449                "unknown vector encoding {other:?}; supported: SQ8, HALF"
6450            ))),
6451        }
6452    }
6453
6454    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
6455    /// without consuming it. Returns `Some(N)` when the next
6456    /// tokens are `( <int> )`; None otherwise. Used by the
6457    /// TINYINT classifier to decide whether to map to Bool or
6458    /// SmallInt.
6459    fn peek_optional_paren_size_value(&self) -> Option<i64> {
6460        if !matches!(self.peek(), Token::LParen) {
6461            return None;
6462        }
6463        let next = self.tokens.get(self.pos + 1)?;
6464        let n = match next {
6465            Token::Integer(n) => *n,
6466            _ => return None,
6467        };
6468        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
6469            return None;
6470        }
6471        Some(n)
6472    }
6473
6474    /// v7.14.0 — consume an optional MySQL display-width
6475    /// parenthesised number after an integer type, returning
6476    /// nothing. `TINYINT(1)` etc.
6477    fn consume_optional_paren_size(&mut self) {
6478        if !matches!(self.peek(), Token::LParen) {
6479            return;
6480        }
6481        self.advance();
6482        // Skip until matching RParen (allow nested or any tokens).
6483        let mut depth = 1usize;
6484        while depth > 0 {
6485            match self.peek() {
6486                Token::LParen => depth += 1,
6487                Token::RParen => depth -= 1,
6488                Token::Eof => return,
6489                _ => {}
6490            }
6491            self.advance();
6492        }
6493    }
6494
6495    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
6496        if !matches!(self.peek(), Token::LParen) {
6497            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
6498        }
6499        self.advance();
6500        let n = match self.advance() {
6501            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
6502                message: format!("{label} size too large: {n}"),
6503                token_pos: self.pos.saturating_sub(1),
6504            })?,
6505            other => {
6506                return Err(ParseError {
6507                    message: format!("expected positive integer {label} size, got {other:?}"),
6508                    token_pos: self.pos.saturating_sub(1),
6509                });
6510            }
6511        };
6512        if !matches!(self.peek(), Token::RParen) {
6513            return Err(self.err(format!(
6514                "expected ')' after {label} size, got {:?}",
6515                self.peek()
6516            )));
6517        }
6518        self.advance();
6519        Ok(n)
6520    }
6521
6522    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
6523        debug_assert!(matches!(self.peek(), Token::Insert));
6524        self.advance();
6525        if !matches!(self.peek(), Token::Into) {
6526            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
6527        }
6528        self.advance();
6529        let table = self.expect_ident_like()?;
6530        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
6531        let columns = if matches!(self.peek(), Token::LParen) {
6532            self.advance();
6533            let mut names = Vec::new();
6534            loop {
6535                names.push(self.expect_ident_like()?);
6536                match self.peek() {
6537                    Token::Comma => {
6538                        self.advance();
6539                    }
6540                    Token::RParen => {
6541                        self.advance();
6542                        break;
6543                    }
6544                    other => {
6545                        return Err(self.err(format!(
6546                            "expected ',' or ')' in INSERT column list, got {other:?}"
6547                        )));
6548                    }
6549                }
6550            }
6551            Some(names)
6552        } else {
6553            None
6554        };
6555        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6556        // round-5 G4). Dispatch on VALUES vs SELECT.
6557        if matches!(self.peek(), Token::Select) {
6558            let select_stmt = match self.parse_select_stmt()? {
6559                Statement::Select(s) => s,
6560                other => {
6561                    return Err(self.err(alloc::format!(
6562                        "expected SELECT after INSERT INTO ... target, got {other:?}"
6563                    )));
6564                }
6565            };
6566            let on_conflict = self.parse_optional_on_conflict()?;
6567            let returning = self.parse_optional_returning()?;
6568            return Ok(Statement::Insert(InsertStatement {
6569                table,
6570                columns,
6571                rows: Vec::new(),
6572                select_source: Some(Box::new(select_stmt)),
6573                on_conflict,
6574                returning,
6575            }));
6576        }
6577        if !matches!(self.peek(), Token::Values) {
6578            return Err(self.err(format!(
6579                "expected VALUES or SELECT after table name, got {:?}",
6580                self.peek()
6581            )));
6582        }
6583        self.advance();
6584        if !matches!(self.peek(), Token::LParen) {
6585            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
6586        }
6587        let mut rows = Vec::new();
6588        loop {
6589            // Each iteration consumes one `(expr, expr, …)` tuple.
6590            if !matches!(self.peek(), Token::LParen) {
6591                return Err(self.err(format!(
6592                    "expected '(' for next VALUES tuple, got {:?}",
6593                    self.peek()
6594                )));
6595            }
6596            self.advance();
6597            let mut tuple = Vec::new();
6598            loop {
6599                tuple.push(self.parse_expr(0)?);
6600                match self.peek() {
6601                    Token::Comma => {
6602                        self.advance();
6603                    }
6604                    Token::RParen => {
6605                        self.advance();
6606                        break;
6607                    }
6608                    other => {
6609                        return Err(self.err(format!(
6610                            "expected ',' or ')' in VALUES tuple, got {other:?}"
6611                        )));
6612                    }
6613                }
6614            }
6615            if tuple.is_empty() {
6616                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
6617            }
6618            rows.push(tuple);
6619            // Continue with comma-separated tuples.
6620            if matches!(self.peek(), Token::Comma) {
6621                self.advance();
6622            } else {
6623                break;
6624            }
6625        }
6626        let on_conflict = self.parse_optional_on_conflict()?;
6627        let returning = self.parse_optional_returning()?;
6628        Ok(Statement::Insert(InsertStatement {
6629            table,
6630            columns,
6631            rows,
6632            select_source: None,
6633            on_conflict,
6634            returning,
6635        }))
6636    }
6637
6638    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
6639    /// clause sitting between the INSERT body and the trailing
6640    /// RETURNING. All keywords come in as bare idents; `ON` is
6641    /// a reserved Token though.
6642    fn parse_optional_on_conflict(
6643        &mut self,
6644    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
6645        if !matches!(self.peek(), Token::On) {
6646            return Ok(None);
6647        }
6648        // Peek further: we want exactly "ON CONFLICT ...". If the
6649        // next ident isn't "conflict", let some other parser handle.
6650        let next_is_conflict = matches!(
6651            self.tokens.get(self.pos + 1),
6652            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
6653        );
6654        if !next_is_conflict {
6655            return Ok(None);
6656        }
6657        self.advance(); // ON
6658        self.advance(); // CONFLICT
6659        // Optional `(col [, col]*)` target list.
6660        let mut target_columns: Vec<String> = Vec::new();
6661        if matches!(self.peek(), Token::LParen) {
6662            self.advance();
6663            loop {
6664                target_columns.push(self.expect_ident_like()?);
6665                match self.peek() {
6666                    Token::Comma => {
6667                        self.advance();
6668                    }
6669                    Token::RParen => {
6670                        self.advance();
6671                        break;
6672                    }
6673                    other => {
6674                        return Err(self.err(alloc::format!(
6675                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
6676                        )));
6677                    }
6678                }
6679            }
6680        }
6681        // Required `DO`.
6682        match self.advance() {
6683            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
6684            other => {
6685                return Err(self.err(alloc::format!(
6686                    "expected DO after ON CONFLICT [(…)], got {other:?}"
6687                )));
6688            }
6689        }
6690        // Action: NOTHING | UPDATE SET …
6691        let action = match self.advance() {
6692            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
6693                crate::ast::OnConflictAction::Nothing
6694            }
6695            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
6696                self.parse_on_conflict_update_action()?
6697            }
6698            other => {
6699                return Err(self.err(alloc::format!(
6700                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
6701                )));
6702            }
6703        };
6704        Ok(Some(crate::ast::OnConflictClause {
6705            target_columns,
6706            action,
6707        }))
6708    }
6709
6710    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
6711    /// `SET col = expr [, …] [WHERE cond]`. Caller already
6712    /// consumed `UPDATE`.
6713    fn parse_on_conflict_update_action(
6714        &mut self,
6715    ) -> Result<crate::ast::OnConflictAction, ParseError> {
6716        // `SET`
6717        match self.advance() {
6718            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
6719            other => {
6720                return Err(self.err(alloc::format!(
6721                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
6722                )));
6723            }
6724        }
6725        let mut assignments: Vec<(String, Expr)> = Vec::new();
6726        loop {
6727            let col = self.expect_ident_like()?;
6728            if !matches!(self.peek(), Token::Eq) {
6729                return Err(self.err(alloc::format!(
6730                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
6731                    self.peek()
6732                )));
6733            }
6734            self.advance();
6735            let value = self.parse_expr(0)?;
6736            assignments.push((col, value));
6737            if matches!(self.peek(), Token::Comma) {
6738                self.advance();
6739                continue;
6740            }
6741            break;
6742        }
6743        let where_ = if matches!(self.peek(), Token::Where) {
6744            self.advance();
6745            Some(self.parse_expr(0)?)
6746        } else {
6747            None
6748        };
6749        Ok(crate::ast::OnConflictAction::Update {
6750            assignments,
6751            where_,
6752        })
6753    }
6754
6755    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
6756        let mut items = Vec::new();
6757        loop {
6758            items.push(self.parse_select_item()?);
6759            if matches!(self.peek(), Token::Comma) {
6760                self.advance();
6761            } else {
6762                break;
6763            }
6764        }
6765        Ok(items)
6766    }
6767
6768    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
6769        if matches!(self.peek(), Token::Star) {
6770            self.advance();
6771            return Ok(SelectItem::Wildcard);
6772        }
6773        let expr = self.parse_expr(0)?;
6774        let alias = self.parse_optional_alias();
6775        Ok(SelectItem::Expr { expr, alias })
6776    }
6777
6778    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
6779        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
6780        // table. Detect at the head so it claims precedence over
6781        // every other table-ref shape (unnest / generate_series /
6782        // bare ident); the lateral subquery itself follows the
6783        // regular SELECT grammar.
6784        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
6785            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6786        {
6787            self.advance(); // LATERAL
6788            self.advance(); // (
6789            // Parse the inner SELECT.
6790            let inner = match self.parse_one_statement()? {
6791                Statement::Select(s) => s,
6792                other => {
6793                    return Err(self.err(alloc::format!(
6794                        "expected SELECT inside LATERAL ( … ), got {other:?}"
6795                    )));
6796                }
6797            };
6798            if !matches!(self.peek(), Token::RParen) {
6799                return Err(self.err(alloc::format!(
6800                    "expected ')' after LATERAL subquery, got {:?}",
6801                    self.peek()
6802                )));
6803            }
6804            self.advance();
6805            let alias_ident = self.parse_optional_alias();
6806            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
6807            return Ok(TableRef {
6808                name,
6809                alias: alias_ident,
6810                as_of_segment: None,
6811                unnest_expr: None,
6812                unnest_column_aliases: Vec::new(),
6813                generate_series_args: None,
6814                lateral_subquery: Some(Box::new(inner)),
6815            });
6816        }
6817        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
6818        // source. Detect at the head before the bare-ident fallback;
6819        // unnest is not a reserved token.
6820        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
6821            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6822        {
6823            self.advance(); // unnest
6824            self.advance(); // (
6825            let expr = self.parse_expr(0)?;
6826            if !matches!(self.peek(), Token::RParen) {
6827                return Err(self.err(alloc::format!(
6828                    "expected ')' after unnest() argument, got {:?}",
6829                    self.peek()
6830                )));
6831            }
6832            self.advance();
6833            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
6834            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
6835            return Ok(TableRef {
6836                name,
6837                alias: alias_ident,
6838                as_of_segment: None,
6839                unnest_expr: Some(Box::new(expr)),
6840                unnest_column_aliases,
6841                generate_series_args: None,
6842                lateral_subquery: None,
6843            });
6844        }
6845        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
6846        // [, step])` set-returning source. Same shape as unnest:
6847        // detect at the head, parse the comma-separated arg list,
6848        // dispatch downstream through the engine's set-returning
6849        // path. Supports integer triplets (mailrs's `WITH row_no AS
6850        // (SELECT * FROM generate_series(1, N))` pattern) and
6851        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
6852        // date-range iteration pattern, which pre-3.10 had no
6853        // direct equivalent in SPG).
6854        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
6855            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6856        {
6857            self.advance(); // generate_series
6858            self.advance(); // (
6859            let mut args: Vec<Expr> = Vec::new();
6860            loop {
6861                args.push(self.parse_expr(0)?);
6862                if matches!(self.peek(), Token::Comma) {
6863                    self.advance();
6864                    continue;
6865                }
6866                break;
6867            }
6868            if !matches!(self.peek(), Token::RParen) {
6869                return Err(self.err(alloc::format!(
6870                    "expected ')' after generate_series() arguments, got {:?}",
6871                    self.peek()
6872                )));
6873            }
6874            self.advance();
6875            if args.len() < 2 || args.len() > 3 {
6876                return Err(self.err(alloc::format!(
6877                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
6878                    args.len()
6879                )));
6880            }
6881            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
6882            let name = alias_ident
6883                .clone()
6884                .unwrap_or_else(|| "generate_series".to_string());
6885            return Ok(TableRef {
6886                name,
6887                alias: alias_ident,
6888                as_of_segment: None,
6889                unnest_expr: None,
6890                unnest_column_aliases: Vec::new(),
6891                generate_series_args: Some(args),
6892                lateral_subquery: None,
6893            });
6894        }
6895        // v7.16.2 — preserve information_schema / pg_catalog
6896        // qualifiers (mailrs round-10 A.3). The generic
6897        // `expect_ident_like` strip silently drops the schema;
6898        // we want the engine to recognise these PG meta tables
6899        // and synthesise rows from the live catalog. Produce a
6900        // synthetic name (`__spg_info_columns` etc.) so the
6901        // engine's SELECT-side router can dispatch without
6902        // clashing with any user-defined `columns` table.
6903        let name = if let Some(synth) = self.try_peek_meta_qualified() {
6904            synth
6905        } else {
6906            self.expect_ident_like()?
6907        };
6908        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
6909        // time-travel clause. Parse BEFORE the alias so the
6910        // alias can still ride at the tail (`tbl AS OF SEGMENT
6911        // '5' alias`). `AS` is a reserved keyword token, while
6912        // `OF` and `SEGMENT` are bare idents.
6913        let as_of_segment = if matches!(self.peek(), Token::As)
6914            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
6915        {
6916            self.advance(); // AS
6917            self.advance(); // OF
6918            let kw = match self.peek().clone() {
6919                Token::Ident(s) | Token::QuotedIdent(s) => s,
6920                other => {
6921                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
6922                }
6923            };
6924            if !kw.eq_ignore_ascii_case("segment") {
6925                return Err(self.err(format!(
6926                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
6927                )));
6928            }
6929            self.advance();
6930            // Segment id literal — accept either a string or
6931            // integer for operator ergonomics.
6932            let id = match self.advance() {
6933                Token::String(s) => s
6934                    .parse::<u32>()
6935                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
6936                Token::Integer(n) => u32::try_from(n)
6937                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
6938                other => {
6939                    return Err(self.err(format!(
6940                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
6941                    )));
6942                }
6943            };
6944            Some(id)
6945        } else {
6946            None
6947        };
6948        let alias = self.parse_optional_alias();
6949        Ok(TableRef {
6950            name,
6951            alias,
6952            as_of_segment,
6953            unnest_expr: None,
6954            unnest_column_aliases: Vec::new(),
6955            generate_series_args: None,
6956            lateral_subquery: None,
6957        })
6958    }
6959
6960    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
6961    /// but also accepts `AS alias(col [, col, …])` — the
6962    /// PG-standard table-function column-list form. The column
6963    /// list is only honoured when paired with `UNNEST(...)` in
6964    /// the parent; other call sites currently discard it.
6965    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
6966        let alias = self.parse_optional_alias();
6967        if alias.is_none() {
6968            return (None, Vec::new());
6969        }
6970        let mut cols: Vec<String> = Vec::new();
6971        if matches!(self.peek(), Token::LParen) {
6972            self.advance();
6973            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
6974                self.advance();
6975                cols.push(s);
6976                if matches!(self.peek(), Token::Comma) {
6977                    self.advance();
6978                    continue;
6979                }
6980                break;
6981            }
6982            if matches!(self.peek(), Token::RParen) {
6983                self.advance();
6984            }
6985        }
6986        (alias, cols)
6987    }
6988
6989    /// FROM-clause: a primary table reference plus zero-or-more joined
6990    /// peers expressed via either `, <table>` (cross-product, no ON) or
6991    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
6992    /// the join list flat (left-associative nested-loop semantics).
6993    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
6994        let primary = self.parse_table_ref()?;
6995        let mut joins = Vec::new();
6996        loop {
6997            // `, <table>` — cross-product with no ON.
6998            if matches!(self.peek(), Token::Comma) {
6999                self.advance();
7000                let table = self.parse_table_ref()?;
7001                joins.push(FromJoin {
7002                    kind: JoinKind::Cross,
7003                    table,
7004                    on: None,
7005                });
7006                continue;
7007            }
7008            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
7009            // CROSS JOIN, and bare JOIN (defaults to INNER).
7010            let kind =
7011                match self.peek() {
7012                    Token::Inner => {
7013                        self.advance();
7014                        if !matches!(self.peek(), Token::Join) {
7015                            return Err(self
7016                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
7017                        }
7018                        self.advance();
7019                        JoinKind::Inner
7020                    }
7021                    Token::Left => {
7022                        self.advance();
7023                        if matches!(self.peek(), Token::Outer) {
7024                            self.advance();
7025                        }
7026                        if !matches!(self.peek(), Token::Join) {
7027                            return Err(self.err(format!(
7028                                "expected JOIN after LEFT [OUTER], got {:?}",
7029                                self.peek()
7030                            )));
7031                        }
7032                        self.advance();
7033                        JoinKind::Left
7034                    }
7035                    Token::Cross => {
7036                        self.advance();
7037                        if !matches!(self.peek(), Token::Join) {
7038                            return Err(self
7039                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
7040                        }
7041                        self.advance();
7042                        JoinKind::Cross
7043                    }
7044                    Token::Join => {
7045                        self.advance();
7046                        JoinKind::Inner
7047                    }
7048                    _ => break,
7049                };
7050            let table = self.parse_table_ref()?;
7051            let on = if matches!(self.peek(), Token::On) {
7052                self.advance();
7053                Some(self.parse_expr(0)?)
7054            } else if kind == JoinKind::Cross {
7055                None
7056            } else {
7057                return Err(self.err(format!(
7058                    "expected ON after {:?} JOIN, got {:?}",
7059                    kind,
7060                    self.peek()
7061                )));
7062            };
7063            joins.push(FromJoin { kind, table, on });
7064        }
7065        Ok(FromClause { primary, joins })
7066    }
7067
7068    /// Optional alias after an expression or table:
7069    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
7070    /// accepted (PG-style implicit alias). Returns `None` if the next token
7071    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
7072    fn parse_optional_alias(&mut self) -> Option<String> {
7073        if matches!(self.peek(), Token::As) {
7074            self.advance();
7075            // After AS, the next token MUST be an identifier-like — if not,
7076            // we still return None and let the caller surface the error on the
7077            // next expectation. v0.2 keeps the alias path forgiving; the
7078            // corpus tests don't exercise the malformed case.
7079            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
7080                return self.expect_ident_like().ok();
7081            }
7082            return None;
7083        }
7084        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
7085        // grammar reserves a long list of follow-keywords from the
7086        // alias slot. SPG's bareword approximation: skip a small
7087        // set of idents that would otherwise be swallowed as the
7088        // table alias and break trailing clauses like CREATE
7089        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
7090        // CONFLICT WHERE shapes.
7091        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
7092            if is_alias_stopword(s) {
7093                return None;
7094            }
7095            return self.expect_ident_like().ok();
7096        }
7097        None
7098    }
7099
7100    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
7101    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7102        let mut lhs = self.parse_unary()?;
7103        while let Some((op, prec)) = binop_from(self.peek()) {
7104            if prec < min_prec {
7105                break;
7106            }
7107            self.advance();
7108            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
7109            // ANY is a bare ident; ALL is a reserved Token. Both
7110            // require an immediate `(` to disambiguate from
7111            // identifier columns named `any` / `all`.
7112            let any_kind = match self.peek() {
7113                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
7114                    Some(false)
7115                }
7116                Token::Ident(s) | Token::QuotedIdent(s)
7117                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
7118                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
7119                {
7120                    Some(s.eq_ignore_ascii_case("any"))
7121                }
7122                _ => None,
7123            };
7124            if let Some(is_any) = any_kind {
7125                self.advance(); // ident
7126                self.advance(); // (
7127                let arr = self.parse_expr(0)?;
7128                if !matches!(self.peek(), Token::RParen) {
7129                    return Err(self.err(alloc::format!(
7130                        "expected ')' after ANY/ALL argument, got {:?}",
7131                        self.peek()
7132                    )));
7133                }
7134                self.advance();
7135                lhs = Expr::AnyAll {
7136                    expr: Box::new(lhs),
7137                    op,
7138                    array: Box::new(arr),
7139                    is_any,
7140                };
7141                continue;
7142            }
7143            let rhs = self.parse_expr(prec + 1)?;
7144            lhs = Expr::Binary {
7145                lhs: Box::new(lhs),
7146                op,
7147                rhs: Box::new(rhs),
7148            };
7149        }
7150        Ok(lhs)
7151    }
7152
7153    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
7154        match self.peek() {
7155            Token::Not => {
7156                self.advance();
7157                // NOT sits between AND (2) and comparisons (4) — bind everything
7158                // ≥3, which leaves AND/OR outside.
7159                let e = self.parse_expr(3)?;
7160                Ok(Expr::Unary {
7161                    op: UnOp::Not,
7162                    expr: Box::new(e),
7163                })
7164            }
7165            Token::Minus => {
7166                self.advance();
7167                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
7168                // `<->` slotted into 5 and arithmetic shifted up).
7169                let e = self.parse_expr(8)?;
7170                Ok(Expr::Unary {
7171                    op: UnOp::Neg,
7172                    expr: Box::new(e),
7173                })
7174            }
7175            _ => self.parse_atom(),
7176        }
7177    }
7178
7179    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
7180        let tok_pos = self.pos;
7181        match self.advance() {
7182            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
7183            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
7184            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
7185            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
7186            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
7187            Token::Null => Ok(Expr::Literal(Literal::Null)),
7188            // v6.1.1 — `$N` placeholder. The actual Value lookup
7189            // happens in the engine eval path against the prepared-
7190            // statement bind buffer.
7191            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
7192            Token::LParen => {
7193                // v4.10: `(SELECT ...)` in expression position is a
7194                // scalar subquery; otherwise it's a parenthesised
7195                // expression. Peek for SELECT keyword to dispatch.
7196                if matches!(self.peek(), Token::Select) {
7197                    let inner = self.parse_select_stmt()?;
7198                    match self.advance() {
7199                        Token::RParen => {
7200                            let Statement::Select(s) = inner else {
7201                                unreachable!("parse_select_stmt returns Select")
7202                            };
7203                            Ok(Expr::ScalarSubquery(Box::new(s)))
7204                        }
7205                        other => Err(ParseError {
7206                            message: format!("expected ')' after scalar subquery, got {other:?}"),
7207                            token_pos: self.pos.saturating_sub(1),
7208                        }),
7209                    }
7210                } else {
7211                    let e = self.parse_expr(0)?;
7212                    match self.advance() {
7213                        Token::RParen => Ok(e),
7214                        other => Err(ParseError {
7215                            message: format!("expected ')', got {other:?}"),
7216                            token_pos: self.pos.saturating_sub(1),
7217                        }),
7218                    }
7219                }
7220            }
7221            Token::LBracket => self.parse_vector_literal_body(),
7222            Token::Extract => self.parse_extract_atom(),
7223            Token::Interval => self.parse_interval_atom(),
7224            // `LEFT` is a reserved-keyword token because the
7225            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
7226            // When `left` is followed by `(` we're in expression
7227            // position calling the PG `left(string, n)` function;
7228            // rebuild the AST as a regular function call so the
7229            // engine's apply_function dispatch picks it up.
7230            Token::Left if matches!(self.peek(), Token::LParen) => {
7231                self.advance(); // (
7232                let mut args = Vec::new();
7233                if !matches!(self.peek(), Token::RParen) {
7234                    loop {
7235                        args.push(self.parse_expr(0)?);
7236                        match self.peek() {
7237                            Token::Comma => {
7238                                self.advance();
7239                            }
7240                            Token::RParen => break,
7241                            other => {
7242                                return Err(self.err(alloc::format!(
7243                                    "expected ',' or ')' in left() args, got {other:?}"
7244                                )));
7245                            }
7246                        }
7247                    }
7248                }
7249                self.advance(); // )
7250                Ok(Expr::FunctionCall {
7251                    name: "left".into(),
7252                    args,
7253                })
7254            }
7255            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
7256            // token; we match on the bare ident. NOT is a token
7257            // (consumed in the comparison rung), but `EXISTS (...)`
7258            // at the top of an expression starts here.
7259            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
7260                self.parse_exists_atom(false)
7261            }
7262            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
7263            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
7264            // CASE is a bare ident; we dispatch on lowercase match.
7265            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
7266                self.parse_case_atom()
7267            }
7268            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
7269            // is not a reserved token; we match by case-insensitive
7270            // ident. The opening `[` must follow immediately.
7271            Token::Ident(s) | Token::QuotedIdent(s)
7272                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
7273            {
7274                self.advance(); // consume `[`
7275                let mut items: Vec<Expr> = Vec::new();
7276                if !matches!(self.peek(), Token::RBracket) {
7277                    loop {
7278                        items.push(self.parse_expr(0)?);
7279                        match self.peek() {
7280                            Token::Comma => {
7281                                self.advance();
7282                            }
7283                            Token::RBracket => break,
7284                            other => {
7285                                return Err(self.err(alloc::format!(
7286                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
7287                                )));
7288                            }
7289                        }
7290                    }
7291                }
7292                self.advance(); // consume `]`
7293                Ok(Expr::Array(items))
7294            }
7295            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
7296            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
7297            // We special-case before the generic ident dispatch so
7298            // the AGAINST clause never reaches the function-call
7299            // loop (which would mis-read `(cols) AGAINST` as a
7300            // call with no trailing modifier). The shape is
7301            // rewritten to a Boolean OR over per-column
7302            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7303            // term)` so the existing FTS evaluator handles
7304            // semantics — the fulltext-GIN built at CREATE TABLE
7305            // time is currently a "real index that survives dump
7306            // round-trip"; the planner hook that actually uses
7307            // it for posting-list intersection lands in a later
7308            // sub-phase (Phase 2.2b) without touching this surface.
7309            Token::Ident(s) | Token::QuotedIdent(s)
7310                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
7311            {
7312                self.parse_match_against_atom()
7313            }
7314            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
7315            other => Err(ParseError {
7316                message: format!("unexpected token {other:?} in expression"),
7317                token_pos: tok_pos,
7318            }),
7319        }
7320        // After parsing the atom, fold any postfix `::vector` casts.
7321        .and_then(|atom| self.finish_postfix_casts(atom))
7322    }
7323
7324    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
7325    /// Both bind tighter than any binary op.
7326    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
7327        loop {
7328            if matches!(self.peek(), Token::DoubleColon) {
7329                self.advance();
7330                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
7331                // target set to include INTERVAL (reserved Token),
7332                // TIMESTAMPTZ, and PG catalog regtype / regclass.
7333                // mailrs follow-up H3a + H3b.
7334                let target = match self.advance() {
7335                    Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
7336                        "int" | "integer" | "int4" => {
7337                            if matches!(self.peek(), Token::LBracket)
7338                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7339                            {
7340                                self.advance();
7341                                self.advance();
7342                                CastTarget::IntArray
7343                            } else {
7344                                CastTarget::Int
7345                            }
7346                        }
7347                        "bigint" | "int8" => {
7348                            if matches!(self.peek(), Token::LBracket)
7349                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7350                            {
7351                                self.advance();
7352                                self.advance();
7353                                CastTarget::BigIntArray
7354                            } else {
7355                                CastTarget::BigInt
7356                            }
7357                        }
7358                        "float" | "double" | "real" => CastTarget::Float,
7359                        "text" => {
7360                            // v7.10.11 — `::TEXT[]` widens to TextArray.
7361                            if matches!(self.peek(), Token::LBracket)
7362                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7363                            {
7364                                self.advance();
7365                                self.advance();
7366                                CastTarget::TextArray
7367                            } else {
7368                                CastTarget::Text
7369                            }
7370                        }
7371                        "bool" | "boolean" => CastTarget::Bool,
7372                        "vector" => CastTarget::Vector,
7373                        "date" => CastTarget::Date,
7374                        "timestamp" | "datetime" => CastTarget::Timestamp,
7375                        "timestamptz" => CastTarget::Timestamptz,
7376                        "interval" => CastTarget::Interval,
7377                        "json" => CastTarget::Json,
7378                        "jsonb" => CastTarget::Jsonb,
7379                        "regtype" => CastTarget::RegType,
7380                        "regclass" => CastTarget::RegClass,
7381                        // v7.12.0 — `::tsvector` / `::tsquery`.
7382                        // Engine decodes the LHS text via the PG
7383                        // external form parser.
7384                        "tsvector" => CastTarget::TsVector,
7385                        "tsquery" => CastTarget::TsQuery,
7386                        // v7.17.0 — `::uuid`. Engine decodes the LHS
7387                        // text via `spg_storage::parse_uuid_str`.
7388                        "uuid" => CastTarget::Uuid,
7389                        // v7.17.0 Phase 3.P0-47 — `::inet` / `::cidr` /
7390                        // `::macaddr`. SPG stores these as Text (Phase 7);
7391                        // the cast is a no-op passthrough so containment
7392                        // and overlap operators can read the textual form.
7393                        "inet" | "cidr" | "macaddr" => CastTarget::Text,
7394                        other => {
7395                            return Err(ParseError {
7396                                message: format!("unsupported cast target `::{other}`"),
7397                                token_pos: self.pos.saturating_sub(1),
7398                            });
7399                        }
7400                    },
7401                    Token::Interval => CastTarget::Interval,
7402                    other => {
7403                        return Err(ParseError {
7404                            message: format!("expected type ident after `::`, got {other:?}"),
7405                            token_pos: self.pos.saturating_sub(1),
7406                        });
7407                    }
7408                };
7409                expr = Expr::Cast {
7410                    expr: Box::new(expr),
7411                    target,
7412                };
7413                continue;
7414            }
7415            if matches!(self.peek(), Token::Is) {
7416                self.advance();
7417                let negated = if matches!(self.peek(), Token::Not) {
7418                    self.advance();
7419                    true
7420                } else {
7421                    false
7422                };
7423                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
7424                // mailrs pg_dump.
7425                if matches!(self.peek(), Token::Distinct) {
7426                    self.advance();
7427                    if !matches!(self.peek(), Token::From) {
7428                        return Err(self.err(format!(
7429                            "expected FROM after IS{} DISTINCT, got {:?}",
7430                            if negated { " NOT" } else { "" },
7431                            self.peek()
7432                        )));
7433                    }
7434                    self.advance();
7435                    // Right-hand side: parse at the same precedence
7436                    // tier as comparison so `x IS DISTINCT FROM a + b`
7437                    // groups as `x IS DISTINCT FROM (a + b)`.
7438                    let rhs = self.parse_expr(20)?;
7439                    let op = if negated {
7440                        BinOp::IsNotDistinctFrom
7441                    } else {
7442                        BinOp::IsDistinctFrom
7443                    };
7444                    expr = Expr::Binary {
7445                        op,
7446                        lhs: Box::new(expr),
7447                        rhs: Box::new(rhs),
7448                    };
7449                    continue;
7450                }
7451                if !matches!(self.peek(), Token::Null) {
7452                    return Err(self.err(format!(
7453                        "expected NULL or DISTINCT after IS{}, got {:?}",
7454                        if negated { " NOT" } else { "" },
7455                        self.peek()
7456                    )));
7457                }
7458                self.advance();
7459                expr = Expr::IsNull {
7460                    expr: Box::new(expr),
7461                    negated,
7462                };
7463                continue;
7464            }
7465            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
7466            // Look one token ahead so a stray `NOT` not followed by any of
7467            // these flows through to the early return below untouched.
7468            let negated = if matches!(self.peek(), Token::Not) {
7469                let next = self.tokens.get(self.pos + 1);
7470                matches!(next, Some(Token::Between | Token::In | Token::Like))
7471            } else {
7472                false
7473            };
7474            if negated {
7475                self.advance();
7476            }
7477            if matches!(self.peek(), Token::Between) {
7478                expr = self.parse_between_tail(expr, negated)?;
7479                continue;
7480            }
7481            if matches!(self.peek(), Token::In) {
7482                expr = self.parse_in_tail(expr, negated)?;
7483                continue;
7484            }
7485            if matches!(self.peek(), Token::Like) {
7486                self.advance();
7487                // Pattern at the same precedence as other comparison RHSes —
7488                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
7489                let pattern = self.parse_expr(5)?;
7490                expr = Expr::Like {
7491                    expr: Box::new(expr),
7492                    pattern: Box::new(pattern),
7493                    negated,
7494                };
7495                continue;
7496            }
7497            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
7498            // returns NULL for out-of-range. Multiple subscripts
7499            // chain: `a[i][j]` parses left-to-right.
7500            if matches!(self.peek(), Token::LBracket) {
7501                self.advance();
7502                let index = self.parse_expr(0)?;
7503                if !matches!(self.peek(), Token::RBracket) {
7504                    return Err(self.err(alloc::format!(
7505                        "expected ']' after array index, got {:?}",
7506                        self.peek()
7507                    )));
7508                }
7509                self.advance();
7510                expr = Expr::ArraySubscript {
7511                    target: Box::new(expr),
7512                    index: Box::new(index),
7513                };
7514                continue;
7515            }
7516            return Ok(expr);
7517        }
7518    }
7519
7520    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
7521    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
7522    /// `AND` is not swallowed.
7523    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7524        self.advance(); // BETWEEN
7525        let low = self.parse_expr(5)?;
7526        if !matches!(self.peek(), Token::And) {
7527            return Err(self.err(format!(
7528                "expected AND after BETWEEN low bound, got {:?}",
7529                self.peek()
7530            )));
7531        }
7532        self.advance();
7533        let high = self.parse_expr(5)?;
7534        let target = Box::new(expr);
7535        let combined = Expr::Binary {
7536            lhs: Box::new(Expr::Binary {
7537                lhs: target.clone(),
7538                op: BinOp::GtEq,
7539                rhs: Box::new(low),
7540            }),
7541            op: BinOp::And,
7542            rhs: Box::new(Expr::Binary {
7543                lhs: target,
7544                op: BinOp::LtEq,
7545                rhs: Box::new(high),
7546            }),
7547        };
7548        Ok(maybe_not(combined, negated))
7549    }
7550
7551    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
7552    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
7553    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
7554    /// Caller already consumed the leading `WITH` ident.
7555    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
7556        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
7557        // Comes through as an identifier; consume it if present and
7558        // mark every CTE in the clause as recursive (PG semantics —
7559        // the flag is per-WITH, not per-CTE).
7560        let mut recursive = false;
7561        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
7562            && s.eq_ignore_ascii_case("recursive")
7563        {
7564            self.advance();
7565            recursive = true;
7566        }
7567        let mut ctes = Vec::new();
7568        loop {
7569            let name = self.expect_ident_like()?;
7570            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
7571            // PG uses these to rename the body's output columns; we
7572            // do the same below by overriding `columns[i].name`.
7573            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
7574                self.advance();
7575                let mut names = Vec::new();
7576                loop {
7577                    names.push(self.expect_ident_like()?);
7578                    if matches!(self.peek(), Token::Comma) {
7579                        self.advance();
7580                        continue;
7581                    }
7582                    break;
7583                }
7584                if !matches!(self.peek(), Token::RParen) {
7585                    return Err(self.err(format!(
7586                        "expected ')' to close CTE column list, got {:?}",
7587                        self.peek()
7588                    )));
7589                }
7590                self.advance();
7591                names
7592            } else {
7593                Vec::new()
7594            };
7595            // AS is a reserved Token::As (used by SELECT-item / FROM
7596            // aliasing) — handle it specially rather than as a bare
7597            // ident.
7598            if !matches!(self.peek(), Token::As) {
7599                return Err(self.err(format!(
7600                    "expected AS after CTE name {name:?}, got {:?}",
7601                    self.peek()
7602                )));
7603            }
7604            self.advance();
7605            if !matches!(self.peek(), Token::LParen) {
7606                return Err(self.err(format!(
7607                    "expected '(' after AS in WITH clause, got {:?}",
7608                    self.peek()
7609                )));
7610            }
7611            self.advance();
7612            if !matches!(self.peek(), Token::Select) {
7613                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
7614            }
7615            let inner = self.parse_select_stmt()?;
7616            if !matches!(self.peek(), Token::RParen) {
7617                return Err(self.err(format!(
7618                    "expected ')' after CTE body, got {:?}",
7619                    self.peek()
7620                )));
7621            }
7622            self.advance();
7623            let Statement::Select(body) = inner else {
7624                unreachable!("parse_select_stmt returns Select")
7625            };
7626            ctes.push(crate::ast::Cte {
7627                name,
7628                body,
7629                recursive,
7630                column_overrides,
7631            });
7632            if matches!(self.peek(), Token::Comma) {
7633                self.advance();
7634                continue;
7635            }
7636            break;
7637        }
7638        // The body SELECT follows. Must start with SELECT.
7639        if !matches!(self.peek(), Token::Select) {
7640            return Err(self.err(format!(
7641                "expected SELECT after WITH clause, got {:?}",
7642                self.peek()
7643            )));
7644        }
7645        let body_stmt = self.parse_select_stmt()?;
7646        let Statement::Select(mut body) = body_stmt else {
7647            unreachable!()
7648        };
7649        body.ctes = ctes;
7650        Ok(Statement::Select(body))
7651    }
7652
7653    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
7654    /// already consumed the leading `EXISTS` ident via
7655    /// `self.advance()`.
7656    /// v7.13.0 — parse the rest of a `CASE … END` expression after
7657    /// the leading `CASE` ident has been consumed (mailrs round-5
7658    /// G9). Supports both the searched form
7659    /// (`CASE WHEN cond THEN val …`) and the simple form
7660    /// (`CASE operand WHEN val THEN val …`).
7661    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
7662        // Disambiguate searched vs simple form: if the next token
7663        // is `WHEN`, we're in the searched form. Otherwise the
7664        // intervening expression is the operand.
7665        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
7666            None
7667        } else {
7668            Some(Box::new(self.parse_expr(0)?))
7669        };
7670        let mut branches: Vec<(Expr, Expr)> = Vec::new();
7671        loop {
7672            match self.peek() {
7673                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
7674                    self.advance();
7675                    let cond = self.parse_expr(0)?;
7676                    match self.peek() {
7677                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
7678                            self.advance();
7679                        }
7680                        other => {
7681                            return Err(self.err(alloc::format!(
7682                                "expected THEN after CASE WHEN <expr>, got {other:?}"
7683                            )));
7684                        }
7685                    }
7686                    let value = self.parse_expr(0)?;
7687                    branches.push((cond, value));
7688                }
7689                _ => break,
7690            }
7691        }
7692        if branches.is_empty() {
7693            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
7694        }
7695        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
7696        {
7697            self.advance();
7698            Some(Box::new(self.parse_expr(0)?))
7699        } else {
7700            None
7701        };
7702        match self.peek() {
7703            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
7704                self.advance();
7705            }
7706            other => {
7707                return Err(self.err(alloc::format!(
7708                    "expected END to close CASE expression, got {other:?}"
7709                )));
7710            }
7711        }
7712        Ok(Expr::Case {
7713            operand,
7714            branches,
7715            else_branch,
7716        })
7717    }
7718
7719    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
7720        if !matches!(self.peek(), Token::LParen) {
7721            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
7722        }
7723        self.advance();
7724        let inner = self.parse_select_stmt()?;
7725        if !matches!(self.peek(), Token::RParen) {
7726            return Err(self.err(format!(
7727                "expected ')' after EXISTS-subquery, got {:?}",
7728                self.peek()
7729            )));
7730        }
7731        self.advance();
7732        let Statement::Select(s) = inner else {
7733            unreachable!("parse_select_stmt returns Select")
7734        };
7735        Ok(Expr::Exists {
7736            subquery: Box::new(s),
7737            negated,
7738        })
7739    }
7740
7741    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7742        self.advance(); // IN
7743        if !matches!(self.peek(), Token::LParen) {
7744            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
7745        }
7746        self.advance();
7747        // v4.10: `IN (SELECT ...)` — subquery branch.
7748        if matches!(self.peek(), Token::Select) {
7749            let inner = self.parse_select_stmt()?;
7750            if !matches!(self.peek(), Token::RParen) {
7751                return Err(self.err(format!(
7752                    "expected ')' after IN-subquery, got {:?}",
7753                    self.peek()
7754                )));
7755            }
7756            self.advance();
7757            let Statement::Select(s) = inner else {
7758                unreachable!("parse_select_stmt always returns Statement::Select")
7759            };
7760            return Ok(Expr::InSubquery {
7761                expr: Box::new(expr),
7762                subquery: Box::new(s),
7763                negated,
7764            });
7765        }
7766        let mut elements = Vec::new();
7767        if !matches!(self.peek(), Token::RParen) {
7768            loop {
7769                elements.push(self.parse_expr(0)?);
7770                match self.peek() {
7771                    Token::Comma => {
7772                        self.advance();
7773                    }
7774                    Token::RParen => break,
7775                    other => {
7776                        return Err(
7777                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
7778                        );
7779                    }
7780                }
7781            }
7782        }
7783        self.advance(); // ')'
7784        let target = Box::new(expr);
7785        let combined = if elements.is_empty() {
7786            Expr::Literal(Literal::Bool(false))
7787        } else {
7788            let mut iter = elements.into_iter();
7789            let first = iter.next().unwrap();
7790            let mut acc = Expr::Binary {
7791                lhs: target.clone(),
7792                op: BinOp::Eq,
7793                rhs: Box::new(first),
7794            };
7795            for elt in iter {
7796                acc = Expr::Binary {
7797                    lhs: Box::new(acc),
7798                    op: BinOp::Or,
7799                    rhs: Box::new(Expr::Binary {
7800                        lhs: target.clone(),
7801                        op: BinOp::Eq,
7802                        rhs: Box::new(elt),
7803                    }),
7804                };
7805            }
7806            acc
7807        };
7808        Ok(maybe_not(combined, negated))
7809    }
7810
7811    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
7812    /// already consumed by the caller. Elements must be numeric literals
7813    /// (with optional unary `-`); any compound expression is rejected at
7814    /// parse time so the runtime never needs to evaluate inside a vector.
7815    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
7816    /// has already consumed the `EXTRACT` token before calling us —
7817    /// we pick up at the opening `(`.
7818    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
7819    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
7820    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
7821    /// per-column OR-fold of
7822    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7823    /// term)` so the existing FTS evaluator handles semantics.
7824    ///
7825    /// The mode modifier is accepted-and-ignored at v7.17 — all
7826    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
7827    /// mode operators (`+foo -bar`) would need their own parser
7828    /// (Phase 2.2c); customers who hit them today already get a
7829    /// correct lexeme-match against the bare term, only without
7830    /// the +/- precedence the customer asked for.
7831    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
7832        // Already at `MATCH`-consumed position; the dispatcher
7833        // confirmed the next token is `(`.
7834        if !matches!(self.peek(), Token::LParen) {
7835            return Err(self.err(alloc::format!(
7836                "expected '(' after MATCH, got {:?}",
7837                self.peek()
7838            )));
7839        }
7840        self.advance();
7841        let mut cols: Vec<Expr> = Vec::new();
7842        loop {
7843            cols.push(self.parse_expr(0)?);
7844            match self.peek() {
7845                Token::Comma => {
7846                    self.advance();
7847                }
7848                Token::RParen => break,
7849                other => {
7850                    return Err(self.err(alloc::format!(
7851                        "expected ',' or ')' in MATCH column list, got {other:?}"
7852                    )));
7853                }
7854            }
7855        }
7856        self.advance(); // ')'
7857        // Expect AGAINST.
7858        match self.peek() {
7859            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
7860                self.advance();
7861            }
7862            other => {
7863                return Err(self.err(alloc::format!(
7864                    "expected AGAINST after MATCH column list, got {other:?}"
7865                )));
7866            }
7867        }
7868        if !matches!(self.peek(), Token::LParen) {
7869            return Err(self.err(alloc::format!(
7870                "expected '(' after AGAINST, got {:?}",
7871                self.peek()
7872            )));
7873        }
7874        self.advance();
7875        // Read AGAINST's argument as a single primary token —
7876        // string literal, placeholder, or column-ref ident. We
7877        // can't call `parse_expr` / `parse_unary` here because
7878        // the postfix chain inside `parse_atom` would greedily
7879        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
7880        // and fail at "expected '(' after IN". Customers always
7881        // write a literal or bound parameter in AGAINST, so this
7882        // restriction is non-blocking; the error path explains
7883        // the limit if a more complex expression shows up.
7884        let term = match self.advance() {
7885            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
7886            Token::Placeholder(n) => Expr::Placeholder(n),
7887            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
7888                qualifier: None,
7889                name: s,
7890            }),
7891            other => {
7892                return Err(self.err(alloc::format!(
7893                    "MATCH ... AGAINST(<term>) expects a string literal, \
7894                     bound parameter, or column ref, got {other:?}"
7895                )));
7896            }
7897        };
7898        // Optional mode tail — accept-and-ignore at v7.17:
7899        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
7900        //   IN BOOLEAN MODE
7901        //   WITH QUERY EXPANSION
7902        loop {
7903            match self.peek() {
7904                // IN lexes as a reserved Token::In, not an ident,
7905                // so it gets its own arm.
7906                Token::In => {
7907                    self.advance();
7908                }
7909                Token::Ident(s) | Token::QuotedIdent(s)
7910                    if s.eq_ignore_ascii_case("natural")
7911                        || s.eq_ignore_ascii_case("language")
7912                        || s.eq_ignore_ascii_case("boolean")
7913                        || s.eq_ignore_ascii_case("mode")
7914                        || s.eq_ignore_ascii_case("with")
7915                        || s.eq_ignore_ascii_case("query")
7916                        || s.eq_ignore_ascii_case("expansion") =>
7917                {
7918                    self.advance();
7919                }
7920                _ => break,
7921            }
7922        }
7923        if !matches!(self.peek(), Token::RParen) {
7924            return Err(self.err(alloc::format!(
7925                "expected ')' to close AGAINST, got {:?}",
7926                self.peek()
7927            )));
7928        }
7929        self.advance();
7930        // Build per-column `to_tsvector('simple', col) @@
7931        // plainto_tsquery('simple', term)` and OR-fold.
7932        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
7933        let plainto = Expr::FunctionCall {
7934            name: String::from("plainto_tsquery"),
7935            args: alloc::vec![simple_lit(), term.clone()],
7936        };
7937        let mut folded: Option<Expr> = None;
7938        for col in cols {
7939            let to_tsv = Expr::FunctionCall {
7940                name: String::from("to_tsvector"),
7941                args: alloc::vec![simple_lit(), col],
7942            };
7943            let leaf = Expr::Binary {
7944                lhs: Box::new(to_tsv),
7945                op: crate::ast::BinOp::TsMatch,
7946                rhs: Box::new(plainto.clone()),
7947            };
7948            folded = Some(match folded {
7949                None => leaf,
7950                Some(prev) => Expr::Binary {
7951                    lhs: Box::new(prev),
7952                    op: crate::ast::BinOp::Or,
7953                    rhs: Box::new(leaf),
7954                },
7955            });
7956        }
7957        match folded {
7958            Some(e) => Ok(e),
7959            None => Err(self.err(String::from(
7960                "MATCH(...) AGAINST(...) requires at least one column",
7961            ))),
7962        }
7963    }
7964
7965    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
7966        if !matches!(self.peek(), Token::LParen) {
7967            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
7968        }
7969        self.advance();
7970        let field_name = self.expect_ident_like()?;
7971        let field = match field_name.to_ascii_lowercase().as_str() {
7972            "year" => ExtractField::Year,
7973            "month" => ExtractField::Month,
7974            "day" => ExtractField::Day,
7975            "hour" => ExtractField::Hour,
7976            "minute" => ExtractField::Minute,
7977            "second" => ExtractField::Second,
7978            "microsecond" | "microseconds" => ExtractField::Microsecond,
7979            other => {
7980                return Err(self.err(format!(
7981                    "unknown EXTRACT field {other:?}; \
7982                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND"
7983                )));
7984            }
7985        };
7986        if !matches!(self.peek(), Token::From) {
7987            return Err(self.err(format!(
7988                "expected FROM after EXTRACT field, got {:?}",
7989                self.peek()
7990            )));
7991        }
7992        self.advance();
7993        let source = self.parse_expr(0)?;
7994        if !matches!(self.peek(), Token::RParen) {
7995            return Err(self.err(format!(
7996                "expected ')' to close EXTRACT, got {:?}",
7997                self.peek()
7998            )));
7999        }
8000        self.advance();
8001        Ok(Expr::Extract {
8002            field,
8003            source: Box::new(source),
8004        })
8005    }
8006
8007    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
8008    /// is already consumed; we expect a single string literal next and
8009    /// resolve it into `Literal::Interval` at parse time so the engine
8010    /// never has to re-tokenise inside the string.
8011    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
8012        let tok = self.advance();
8013        let Token::String(text) = tok else {
8014            return Err(self.err(format!(
8015                "expected string literal after INTERVAL, got {tok:?}"
8016            )));
8017        };
8018        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
8019            message: format!(
8020                "cannot parse INTERVAL {text:?}; \
8021                     expected `<n> <unit> [<n> <unit> ...]` with units \
8022                     microsecond[s], millisecond[s], second[s], minute[s], \
8023                     hour[s], day[s], week[s], month[s], year[s]"
8024            ),
8025            token_pos: self.pos.saturating_sub(1),
8026        })?;
8027        Ok(Expr::Literal(Literal::Interval {
8028            months,
8029            micros,
8030            text,
8031        }))
8032    }
8033
8034    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
8035        let mut elems = Vec::new();
8036        if matches!(self.peek(), Token::RBracket) {
8037            self.advance();
8038            return Ok(Expr::Literal(Literal::Vector(elems)));
8039        }
8040        loop {
8041            let e = self.parse_expr(0)?;
8042            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
8043                message: format!("vector element must be a numeric literal, got {e:?}"),
8044                token_pos: self.pos,
8045            })?;
8046            elems.push(x);
8047            match self.peek() {
8048                Token::Comma => {
8049                    self.advance();
8050                }
8051                Token::RBracket => {
8052                    self.advance();
8053                    break;
8054                }
8055                other => {
8056                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
8057                }
8058            }
8059        }
8060        Ok(Expr::Literal(Literal::Vector(elems)))
8061    }
8062
8063    /// Atom that started with an identifier: could be `t.col`, `col`, or
8064    /// `func(arg, ...)`. Detect each shape by looking at the next token.
8065    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
8066    /// [, ...])`. Caller has already consumed `OVER`. Either clause
8067    /// is optional; an empty `()` is also legal (PG semantics).
8068    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
8069    /// modifier between `name(args)` and `OVER (...)`. Default is
8070    /// `Respect`. Unrecognised idents leave the stream unchanged.
8071    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
8072        let Token::Ident(s) = self.peek().clone() else {
8073            return NullTreatment::Respect;
8074        };
8075        let is_ignore = s.eq_ignore_ascii_case("ignore");
8076        let is_respect = s.eq_ignore_ascii_case("respect");
8077        if !is_ignore && !is_respect {
8078            return NullTreatment::Respect;
8079        }
8080        // Lookahead for NULLS — only consume both tokens together.
8081        // pos+1 must hold a "nulls" ident.
8082        if self.pos + 1 < self.tokens.len()
8083            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
8084            && s2.eq_ignore_ascii_case("nulls")
8085        {
8086            self.advance();
8087            self.advance();
8088            return if is_ignore {
8089                NullTreatment::Ignore
8090            } else {
8091                NullTreatment::Respect
8092            };
8093        }
8094        NullTreatment::Respect
8095    }
8096
8097    /// No frame clause is supported.
8098    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
8099    fn parse_over_clause(
8100        &mut self,
8101    ) -> Result<(Vec<Expr>, Vec<(Expr, bool)>, Option<WindowFrame>), ParseError> {
8102        if !matches!(self.peek(), Token::LParen) {
8103            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
8104        }
8105        self.advance();
8106        let mut partition_by = Vec::new();
8107        let mut order_by = Vec::new();
8108        // PARTITION BY ?
8109        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8110            && s.eq_ignore_ascii_case("partition")
8111        {
8112            self.advance();
8113            if !matches!(self.peek(), Token::By) {
8114                return Err(self.err(format!(
8115                    "expected BY after PARTITION, got {:?}",
8116                    self.peek()
8117                )));
8118            }
8119            self.advance();
8120            loop {
8121                partition_by.push(self.parse_expr(0)?);
8122                if matches!(self.peek(), Token::Comma) {
8123                    self.advance();
8124                    continue;
8125                }
8126                break;
8127            }
8128        }
8129        // ORDER BY ?
8130        if matches!(self.peek(), Token::Order) {
8131            self.advance();
8132            if !matches!(self.peek(), Token::By) {
8133                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
8134            }
8135            self.advance();
8136            loop {
8137                let e = self.parse_expr(0)?;
8138                let desc = if matches!(self.peek(), Token::Desc) {
8139                    self.advance();
8140                    true
8141                } else if matches!(self.peek(), Token::Asc) {
8142                    self.advance();
8143                    false
8144                } else {
8145                    false
8146                };
8147                order_by.push((e, desc));
8148                if matches!(self.peek(), Token::Comma) {
8149                    self.advance();
8150                    continue;
8151                }
8152                break;
8153            }
8154        }
8155        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
8156        // Both keywords come through the lexer as identifiers; match
8157        // case-insensitively.
8158        let mut frame: Option<WindowFrame> = None;
8159        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8160            let kind = if s.eq_ignore_ascii_case("rows") {
8161                Some(FrameKind::Rows)
8162            } else if s.eq_ignore_ascii_case("range") {
8163                Some(FrameKind::Range)
8164            } else {
8165                None
8166            };
8167            if let Some(kind) = kind {
8168                self.advance();
8169                frame = Some(self.parse_frame_tail(kind)?);
8170            }
8171        }
8172        if !matches!(self.peek(), Token::RParen) {
8173            return Err(self.err(format!(
8174                "expected ')' to close OVER clause, got {:?}",
8175                self.peek()
8176            )));
8177        }
8178        self.advance();
8179        Ok((partition_by, order_by, frame))
8180    }
8181
8182    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
8183    /// or `RANGE` keyword was just consumed. Accepts both
8184    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
8185    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
8186    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
8187    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
8188        if matches!(self.peek(), Token::Between) {
8189            self.advance();
8190            let start = self.parse_frame_bound()?;
8191            if !matches!(self.peek(), Token::And) {
8192                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
8193            }
8194            self.advance();
8195            let end = self.parse_frame_bound()?;
8196            Ok(WindowFrame {
8197                kind,
8198                start,
8199                end: Some(end),
8200            })
8201        } else {
8202            let start = self.parse_frame_bound()?;
8203            Ok(WindowFrame {
8204                kind,
8205                start,
8206                end: None,
8207            })
8208        }
8209    }
8210
8211    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
8212    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
8213    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
8214        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
8215        if let Token::Integer(n) = *self.peek() {
8216            self.advance();
8217            let n: u64 = u64::try_from(n).map_err(|_| {
8218                self.err(format!(
8219                    "invalid frame offset {n} — expected non-negative integer"
8220                ))
8221            })?;
8222            let dir = self.expect_ident_like()?;
8223            return if dir.eq_ignore_ascii_case("preceding") {
8224                Ok(FrameBound::OffsetPreceding(n))
8225            } else if dir.eq_ignore_ascii_case("following") {
8226                Ok(FrameBound::OffsetFollowing(n))
8227            } else {
8228                Err(self.err(format!(
8229                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
8230                )))
8231            };
8232        }
8233        let first = self.expect_ident_like()?;
8234        if first.eq_ignore_ascii_case("unbounded") {
8235            let dir = self.expect_ident_like()?;
8236            return if dir.eq_ignore_ascii_case("preceding") {
8237                Ok(FrameBound::UnboundedPreceding)
8238            } else if dir.eq_ignore_ascii_case("following") {
8239                Ok(FrameBound::UnboundedFollowing)
8240            } else {
8241                Err(self.err(format!(
8242                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
8243                )))
8244            };
8245        }
8246        if first.eq_ignore_ascii_case("current") {
8247            let row = self.expect_ident_like()?;
8248            if !row.eq_ignore_ascii_case("row") {
8249                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
8250            }
8251            return Ok(FrameBound::CurrentRow);
8252        }
8253        Err(self.err(format!(
8254            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
8255        )))
8256    }
8257
8258    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
8259        if matches!(self.peek(), Token::Dot) {
8260            self.advance();
8261            let name = self.expect_ident_like()?;
8262            // v7.14.0 — schema-qualified function call
8263            // `<schema>.<fn>(args)`. PG dumps emit
8264            // `pg_catalog.set_config(...)` in the preamble. SPG
8265            // is single-namespace: drop the schema prefix and
8266            // route the dispatch on the bare function name.
8267            if matches!(self.peek(), Token::LParen) {
8268                return self.finish_ident_atom(name);
8269            }
8270            return Ok(Expr::Column(ColumnName {
8271                qualifier: Some(first),
8272                name,
8273            }));
8274        }
8275        if matches!(self.peek(), Token::LParen) {
8276            self.advance();
8277            // `COUNT(*)` — special-cased here because `*` isn't a normal
8278            // expression token. Lower-case match on `first` since the lexer
8279            // folds identifiers.
8280            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
8281                self.advance();
8282                if !matches!(self.peek(), Token::RParen) {
8283                    return Err(self.err(format!(
8284                        "expected ')' after COUNT(*), got {:?}",
8285                        self.peek()
8286                    )));
8287                }
8288                self.advance();
8289                // v4.12: COUNT(*) OVER (...) — same window tail.
8290                let null_treatment = self.parse_null_treatment_modifier();
8291                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8292                    && s.eq_ignore_ascii_case("over")
8293                {
8294                    self.advance();
8295                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
8296                    return Ok(Expr::WindowFunction {
8297                        name: "count_star".into(),
8298                        args: Vec::new(),
8299                        partition_by,
8300                        order_by,
8301                        frame,
8302                        null_treatment,
8303                    });
8304                }
8305                return Ok(Expr::FunctionCall {
8306                    name: "count_star".into(),
8307                    args: Vec::new(),
8308                });
8309            }
8310            // Function call. PG-style: zero-or-more comma-separated args.
8311            let mut args = Vec::new();
8312            if !matches!(self.peek(), Token::RParen) {
8313                loop {
8314                    args.push(self.parse_expr(0)?);
8315                    match self.peek() {
8316                        Token::Comma => {
8317                            self.advance();
8318                        }
8319                        Token::RParen => break,
8320                        other => {
8321                            return Err(self.err(format!(
8322                                "expected ',' or ')' in function args, got {other:?}"
8323                            )));
8324                        }
8325                    }
8326                }
8327            }
8328            self.advance(); // consume ')'
8329            // v4.12: window-function tail — `name(args) OVER (...)`.
8330            // Promotes the just-parsed FunctionCall into a
8331            // WindowFunction node carrying partition + order.
8332            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
8333            // / `RESPECT NULLS OVER (...)` between the closing paren
8334            // and `OVER`.
8335            let null_treatment = self.parse_null_treatment_modifier();
8336            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8337                && s.eq_ignore_ascii_case("over")
8338            {
8339                self.advance();
8340                let (partition_by, order_by, frame) = self.parse_over_clause()?;
8341                return Ok(Expr::WindowFunction {
8342                    name: first,
8343                    args,
8344                    partition_by,
8345                    order_by,
8346                    frame,
8347                    null_treatment,
8348                });
8349            }
8350            return Ok(Expr::FunctionCall { name: first, args });
8351        }
8352        // v7.9.20 — SQL-standard parenless keyword expressions
8353        // (PG treats these as functions called without parens).
8354        // Resolve to a synthetic FunctionCall so the engine's
8355        // eval path reuses the existing function-call routing.
8356        // mailrs G3.
8357        let lc = first.to_ascii_lowercase();
8358        if matches!(
8359            lc.as_str(),
8360            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
8361        ) {
8362            return Ok(Expr::FunctionCall {
8363                name: lc,
8364                args: Vec::new(),
8365            });
8366        }
8367        Ok(Expr::Column(ColumnName {
8368            qualifier: None,
8369            name: first,
8370        }))
8371    }
8372}
8373
8374/// v6.8.2 — walk an expression tree and return the first column
8375/// reference's bare name. Used by `parse_create_index_stmt_after_create`
8376/// to derive `CreateIndexStatement.column` from an expression
8377/// key (so downstream planner code resolving a primary column
8378/// position keeps working with expression indexes). Returns
8379/// `None` when the expression has no column ref at all — caller
8380/// surfaces that as a parse error.
8381fn extract_first_column(expr: &Expr) -> Option<String> {
8382    match expr {
8383        Expr::Column(cn) => Some(cn.name.clone()),
8384        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
8385        Expr::Binary { lhs, rhs, .. } => {
8386            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
8387        }
8388        Expr::Unary { expr: e, .. } => extract_first_column(e),
8389        _ => None,
8390    }
8391}
8392
8393fn maybe_not(expr: Expr, negated: bool) -> Expr {
8394    if negated {
8395        Expr::Unary {
8396            op: UnOp::Not,
8397            expr: Box::new(expr),
8398        }
8399    } else {
8400        expr
8401    }
8402}
8403
8404fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
8405    let pair = match tok {
8406        Token::Or => (BinOp::Or, 1),
8407        Token::And => (BinOp::And, 2),
8408        Token::Eq => (BinOp::Eq, 4),
8409        Token::NotEq => (BinOp::NotEq, 4),
8410        Token::Lt => (BinOp::Lt, 4),
8411        Token::LtEq => (BinOp::LtEq, 4),
8412        Token::Gt => (BinOp::Gt, 4),
8413        Token::GtEq => (BinOp::GtEq, 4),
8414        // pgvector distance ops all sit on the same rung — tighter than
8415        // comparisons (4) so `col <-> v < threshold` parses correctly.
8416        Token::L2Distance => (BinOp::L2Distance, 5),
8417        Token::InnerProduct => (BinOp::InnerProduct, 5),
8418        Token::CosineDistance => (BinOp::CosineDistance, 5),
8419        Token::Plus => (BinOp::Add, 6),
8420        Token::Minus => (BinOp::Sub, 6),
8421        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
8422        // by the same level as binary additive arithmetic).
8423        Token::Concat => (BinOp::Concat, 6),
8424        Token::Star => (BinOp::Mul, 7),
8425        Token::Slash => (BinOp::Div, 7),
8426        // v4.14: JSON path ops bind tighter than comparisons (4)
8427        // and additive (6) so `doc->'k' = 'v'` parses correctly.
8428        // Same rung as the multiplicative ops.
8429        Token::JsonGet => (BinOp::JsonGet, 7),
8430        Token::JsonGetText => (BinOp::JsonGetText, 7),
8431        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
8432        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
8433        Token::JsonContains => (BinOp::JsonContains, 7),
8434        // v7.12.2 — `@@` binds at the comparison rung (looser than
8435        // arithmetic, tighter than AND / OR). PG places `@@` at
8436        // the same precedence as `=` / `<`, so we follow.
8437        Token::TsMatch => (BinOp::TsMatch, 4),
8438        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
8439        // PG places these at the comparison rung (same level as `=`),
8440        // so we follow.
8441        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
8442        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
8443        Token::InetContains => (BinOp::InetContains, 4),
8444        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
8445        Token::InetOverlap => (BinOp::InetOverlap, 4),
8446        _ => return None,
8447    };
8448    Some(pair)
8449}
8450
8451#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
8452// `as f32` here is intentional: vector elements widen / narrow into f32 on
8453// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
8454// past ~15 decimal digits — both are acceptable for a fixed-precision
8455// pgvector column.
8456/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
8457/// implicit table alias and break trailing clauses. WITH lands
8458/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
8459/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
8460/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
8461/// / VALUES / FOR / LATERAL — all of which would otherwise be
8462/// silently swallowed by `parse_optional_alias`.
8463fn is_alias_stopword(s: &str) -> bool {
8464    matches!(
8465        s.to_ascii_lowercase().as_str(),
8466        "with"
8467            | "on"
8468            | "where"
8469            | "having"
8470            | "group"
8471            | "order"
8472            | "limit"
8473            | "offset"
8474            | "union"
8475            | "except"
8476            | "intersect"
8477            | "returning"
8478            | "set"
8479            | "values"
8480            | "for"
8481            | "lateral"
8482            | "left"
8483            | "right"
8484            | "inner"
8485            | "outer"
8486            | "full"
8487            | "cross"
8488            | "join"
8489            | "natural"
8490            | "using"
8491            | "fetch"
8492    )
8493}
8494
8495fn extract_numeric_literal(e: &Expr) -> Option<f32> {
8496    match e {
8497        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
8498        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
8499        Expr::Unary {
8500            op: UnOp::Neg,
8501            expr,
8502        } => extract_numeric_literal(expr).map(|x| -x),
8503        _ => None,
8504    }
8505}
8506
8507/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
8508/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
8509/// negative. Returns `None` if any pair fails to parse or no pair is found.
8510///
8511/// Recognised units (case-insensitive, optional trailing `s`):
8512/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
8513/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
8514pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
8515    let parts: Vec<&str> = s.split_whitespace().collect();
8516    if parts.is_empty() || !parts.len().is_multiple_of(2) {
8517        return None;
8518    }
8519    let mut months: i32 = 0;
8520    let mut micros: i64 = 0;
8521    let mut i = 0;
8522    while i < parts.len() {
8523        let n: i64 = parts[i].parse().ok()?;
8524        let unit = parts[i + 1].to_ascii_lowercase();
8525        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
8526        match unit_stripped {
8527            "microsecond" => micros = micros.checked_add(n)?,
8528            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
8529            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
8530            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
8531            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
8532            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
8533            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
8534            "month" => {
8535                let n32 = i32::try_from(n).ok()?;
8536                months = months.checked_add(n32)?;
8537            }
8538            "year" => {
8539                let n32 = i32::try_from(n).ok()?;
8540                months = months.checked_add(n32.checked_mul(12)?)?;
8541            }
8542            _ => return None,
8543        }
8544        i += 2;
8545    }
8546    Some((months, micros))
8547}
8548
8549/// v7.12.4 — map a bare type-name identifier (the form that
8550/// appears in a function arg list or RETURNS clause) to a
8551/// [`ColumnTypeName`]. Returns `None` for unknown / extension
8552/// types so the caller can preserve them as
8553/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
8554///
8555/// Subset of the full column-type grammar — we deliberately
8556/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
8557/// here because function-arg types in v7.12.4 are mostly the
8558/// bare form (`text`, `int`, `bytea`, …).
8559fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
8560    Some(match ident.to_ascii_lowercase().as_str() {
8561        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
8562        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
8563        "bigint" => ColumnTypeName::BigInt,
8564        "float" | "double" | "real" => ColumnTypeName::Float,
8565        "text" => ColumnTypeName::Text,
8566        "bool" | "boolean" => ColumnTypeName::Bool,
8567        "date" => ColumnTypeName::Date,
8568        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
8569        "timestamptz" => ColumnTypeName::Timestamptz,
8570        "json" => ColumnTypeName::Json,
8571        "jsonb" => ColumnTypeName::Jsonb,
8572        "bytea" | "bytes" => ColumnTypeName::Bytes,
8573        "tsvector" => ColumnTypeName::TsVector,
8574        "tsquery" => ColumnTypeName::TsQuery,
8575        "uuid" => ColumnTypeName::Uuid,
8576        "time" => ColumnTypeName::Time,
8577        "year" => ColumnTypeName::Year,
8578        "timetz" => ColumnTypeName::TimeTz,
8579        "money" => ColumnTypeName::Money,
8580        _ => return None,
8581    })
8582}
8583
8584/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
8585/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
8586///
8587/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
8588/// / embedded SQL land in v7.12.5+):
8589///
8590/// ```text
8591///   body          := [ws] block [ws]
8592///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
8593///   stmt          := assign | return
8594///   assign        := assign_target := expr
8595///   assign_target := ( NEW | OLD ) . ident | ident
8596///   return        := RETURN ( NEW | OLD | NULL | expr )
8597/// ```
8598///
8599/// `expr` is parsed by recursing into the regular `Parser` — so a
8600/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
8601/// NEW.subject || ' ' || NEW.sender)` body shape works without
8602/// the body parser knowing what `to_tsvector` is.
8603///
8604/// Errors here cause the caller to fall back to
8605/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
8606/// successful, but the executor will refuse to invoke the
8607/// function with an "unparseable body" error.
8608/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
8609/// from the crate root as `spg_sql::parse_function_body`.
8610pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
8611    parse_plpgsql_body(body)
8612}
8613
8614fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
8615    // Use the regular lexer on the body text. The trailing
8616    // `END;` may or may not have a semicolon; the lexer treats
8617    // both forms identically.
8618    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
8619        message: alloc::format!("plpgsql body lex error: {e}"),
8620        token_pos: 0,
8621    })?;
8622    let mut parser = Parser::new(tokens);
8623    parser.parse_plpgsql_block()
8624}
8625
8626#[cfg(test)]
8627mod tests {
8628    use super::*;
8629    use alloc::string::ToString;
8630
8631    fn parse(s: &str) -> Statement {
8632        parse_statement(s).expect("parse ok")
8633    }
8634
8635    fn lit_int(n: i64) -> Expr {
8636        Expr::Literal(Literal::Integer(n))
8637    }
8638
8639    fn col(name: &str) -> Expr {
8640        Expr::Column(ColumnName {
8641            qualifier: None,
8642            name: name.into(),
8643        })
8644    }
8645
8646    #[test]
8647    fn select_single_integer() {
8648        let s = parse("SELECT 1");
8649        let Statement::Select(s) = s else {
8650            panic!("expected SELECT")
8651        };
8652        assert_eq!(s.items.len(), 1);
8653        assert!(s.from.is_none());
8654        assert!(s.where_.is_none());
8655    }
8656
8657    #[test]
8658    fn select_multiple_literal_kinds() {
8659        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
8660        let Statement::Select(s) = s else {
8661            panic!("expected SELECT")
8662        };
8663        assert_eq!(s.items.len(), 5);
8664    }
8665
8666    #[test]
8667    fn select_wildcard_from_table() {
8668        let s = parse("SELECT * FROM users");
8669        let Statement::Select(s) = s else {
8670            panic!("expected SELECT")
8671        };
8672        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
8673        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
8674    }
8675
8676    #[test]
8677    fn select_with_table_alias() {
8678        let s = parse("SELECT * FROM users AS u");
8679        let Statement::Select(s) = s else {
8680            panic!("expected SELECT")
8681        };
8682        let t = &s.from.as_ref().unwrap().primary;
8683        assert_eq!(t.name, "users");
8684        assert_eq!(t.alias.as_deref(), Some("u"));
8685    }
8686
8687    #[test]
8688    fn select_with_where_eq() {
8689        let s = parse("SELECT a FROM t WHERE a = 1");
8690        let Statement::Select(s) = s else {
8691            panic!("expected SELECT")
8692        };
8693        let w = s.where_.unwrap();
8694        assert_eq!(
8695            w,
8696            Expr::Binary {
8697                lhs: Box::new(col("a")),
8698                op: BinOp::Eq,
8699                rhs: Box::new(lit_int(1)),
8700            }
8701        );
8702    }
8703
8704    #[test]
8705    fn arithmetic_precedence() {
8706        let s = parse("SELECT 1 + 2 * 3");
8707        let Statement::Select(s) = s else {
8708            panic!("expected SELECT")
8709        };
8710        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8711            panic!("wildcard?")
8712        };
8713        assert_eq!(
8714            expr,
8715            &Expr::Binary {
8716                lhs: Box::new(lit_int(1)),
8717                op: BinOp::Add,
8718                rhs: Box::new(Expr::Binary {
8719                    lhs: Box::new(lit_int(2)),
8720                    op: BinOp::Mul,
8721                    rhs: Box::new(lit_int(3)),
8722                }),
8723            }
8724        );
8725    }
8726
8727    #[test]
8728    fn parentheses_override_precedence() {
8729        let s = parse("SELECT (1 + 2) * 3");
8730        let Statement::Select(s) = s else {
8731            panic!("expected SELECT")
8732        };
8733        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8734            panic!()
8735        };
8736        assert_eq!(
8737            expr,
8738            &Expr::Binary {
8739                lhs: Box::new(Expr::Binary {
8740                    lhs: Box::new(lit_int(1)),
8741                    op: BinOp::Add,
8742                    rhs: Box::new(lit_int(2)),
8743                }),
8744                op: BinOp::Mul,
8745                rhs: Box::new(lit_int(3)),
8746            }
8747        );
8748    }
8749
8750    #[test]
8751    fn not_binds_below_comparison() {
8752        // `NOT a = 1` should parse as `NOT (a = 1)`.
8753        let s = parse("SELECT NOT a = 1 FROM t");
8754        let Statement::Select(s) = s else {
8755            panic!("expected SELECT")
8756        };
8757        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8758            panic!()
8759        };
8760        assert_eq!(
8761            expr,
8762            &Expr::Unary {
8763                op: UnOp::Not,
8764                expr: Box::new(Expr::Binary {
8765                    lhs: Box::new(col("a")),
8766                    op: BinOp::Eq,
8767                    rhs: Box::new(lit_int(1)),
8768                }),
8769            }
8770        );
8771    }
8772
8773    #[test]
8774    fn unary_minus_binds_above_multiplication() {
8775        // `-a * 2` should be `(-a) * 2`.
8776        let s = parse("SELECT -a * 2 FROM t");
8777        let Statement::Select(s) = s else {
8778            panic!("expected SELECT")
8779        };
8780        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8781            panic!()
8782        };
8783        assert_eq!(
8784            expr,
8785            &Expr::Binary {
8786                lhs: Box::new(Expr::Unary {
8787                    op: UnOp::Neg,
8788                    expr: Box::new(col("a")),
8789                }),
8790                op: BinOp::Mul,
8791                rhs: Box::new(lit_int(2)),
8792            }
8793        );
8794    }
8795
8796    #[test]
8797    fn qualified_column() {
8798        let s = parse("SELECT t.col FROM t");
8799        let Statement::Select(s) = s else {
8800            panic!("expected SELECT")
8801        };
8802        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8803            panic!()
8804        };
8805        assert_eq!(
8806            expr,
8807            &Expr::Column(ColumnName {
8808                qualifier: Some("t".into()),
8809                name: "col".into()
8810            })
8811        );
8812    }
8813
8814    #[test]
8815    fn select_item_alias_with_as() {
8816        let s = parse("SELECT a AS y FROM t");
8817        let Statement::Select(s) = s else {
8818            panic!("expected SELECT")
8819        };
8820        let SelectItem::Expr { alias, .. } = &s.items[0] else {
8821            panic!()
8822        };
8823        assert_eq!(alias.as_deref(), Some("y"));
8824    }
8825
8826    #[test]
8827    fn trailing_semicolon_accepted() {
8828        let s = parse("SELECT 1;");
8829        let Statement::Select(s) = s else {
8830            panic!("expected SELECT")
8831        };
8832        assert_eq!(s.items.len(), 1);
8833    }
8834
8835    #[test]
8836    fn boolean_chain_with_and_or_not() {
8837        // (NOT a) OR (b AND (NOT c))
8838        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
8839        let Statement::Select(s) = s else {
8840            panic!("expected SELECT")
8841        };
8842        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8843            panic!()
8844        };
8845        let expected = Expr::Binary {
8846            lhs: Box::new(Expr::Unary {
8847                op: UnOp::Not,
8848                expr: Box::new(col("a")),
8849            }),
8850            op: BinOp::Or,
8851            rhs: Box::new(Expr::Binary {
8852                lhs: Box::new(col("b")),
8853                op: BinOp::And,
8854                rhs: Box::new(Expr::Unary {
8855                    op: UnOp::Not,
8856                    expr: Box::new(col("c")),
8857                }),
8858            }),
8859        };
8860        assert_eq!(expr, &expected);
8861    }
8862
8863    #[test]
8864    fn empty_input_errors() {
8865        // v7.14.0 — pg_dump preambles emit several comment-only
8866        // / blank-line statements that collapse to Statement::
8867        // Empty rather than a parse error. The old "SELECT in
8868        // message" assertion is stale; verify the new contract:
8869        // empty / whitespace / comment-only input parses to
8870        // Statement::Empty.
8871        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
8872        assert!(matches!(
8873            parse_statement("  \n\t ").unwrap(),
8874            Statement::Empty
8875        ));
8876        // Sanity: malformed-but-non-empty still errors.
8877        assert!(parse_statement("SELECT FROM WHERE").is_err());
8878    }
8879
8880    #[test]
8881    fn unmatched_paren_errors() {
8882        assert!(parse_statement("SELECT (1 + 2").is_err());
8883    }
8884
8885    #[test]
8886    fn display_round_trip_simple_select() {
8887        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
8888        let text = original.to_string();
8889        let again = parse_statement(&text).expect("re-parse");
8890        assert_eq!(original, again);
8891    }
8892
8893    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
8894
8895    #[test]
8896    fn create_table_single_column() {
8897        let s = parse("CREATE TABLE foo (a INT)");
8898        let Statement::CreateTable(c) = s else {
8899            panic!("expected CreateTable")
8900        };
8901        assert_eq!(c.name, "foo");
8902        assert_eq!(c.columns.len(), 1);
8903        assert_eq!(c.columns[0].name, "a");
8904        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
8905        assert!(c.columns[0].nullable);
8906    }
8907
8908    #[test]
8909    fn create_table_multi_column_with_not_null_mix() {
8910        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
8911        let Statement::CreateTable(c) = s else {
8912            panic!()
8913        };
8914        assert_eq!(c.columns.len(), 4);
8915        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
8916        assert!(!c.columns[0].nullable);
8917        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
8918        assert!(c.columns[1].nullable);
8919        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
8920        assert!(!c.columns[2].nullable);
8921        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
8922    }
8923
8924    #[test]
8925    fn create_table_bigint_supported() {
8926        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
8927        let Statement::CreateTable(c) = s else {
8928            panic!()
8929        };
8930        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
8931    }
8932
8933    #[test]
8934    fn create_table_vector_default_is_f32() {
8935        let s = parse("CREATE TABLE t (v VECTOR(128))");
8936        let Statement::CreateTable(c) = s else {
8937            panic!()
8938        };
8939        assert_eq!(
8940            c.columns[0].ty,
8941            ColumnTypeName::Vector {
8942                dim: 128,
8943                encoding: VecEncoding::F32,
8944            },
8945        );
8946    }
8947
8948    #[test]
8949    fn create_table_vector_using_sq8() {
8950        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
8951        // Case-insensitive on both `USING` and the encoding name.
8952        for sql in [
8953            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
8954            "CREATE TABLE t (v VECTOR(128) using sq8)",
8955        ] {
8956            let s = parse(sql);
8957            let Statement::CreateTable(c) = s else {
8958                panic!()
8959            };
8960            assert_eq!(
8961                c.columns[0].ty,
8962                ColumnTypeName::Vector {
8963                    dim: 128,
8964                    encoding: VecEncoding::Sq8,
8965                },
8966                "{sql}",
8967            );
8968        }
8969    }
8970
8971    #[test]
8972    fn create_table_vector_using_unknown_errors() {
8973        // v7.16.1 — the inline `USING <encoding>` shape on
8974        // CREATE TABLE column defs was withdrawn before
8975        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
8976        // (col vector_<metric>_ops)`; the parser now rejects
8977        // USING at column-list position with a clearer
8978        // "expected ',' or ')'" message. Test asserts the
8979        // current rejection, not the old "unknown vector
8980        // encoding" string.
8981        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
8982        assert!(
8983            err.message.contains("USING")
8984                || err.message.contains("using")
8985                || err.message.contains("')'")
8986                || err.message.contains("','"),
8987            "expected USING/column-list rejection, got: {}",
8988            err.message
8989        );
8990    }
8991
8992    #[test]
8993    fn vector_using_sq8_display_roundtrips() {
8994        // The Display impl must produce text that re-parses to the
8995        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
8996        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
8997        let Statement::CreateTable(c) = s else {
8998            panic!()
8999        };
9000        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
9001    }
9002
9003    #[test]
9004    fn parser_recognises_placeholders() {
9005        use crate::ast::{Expr, SelectItem, Statement};
9006        // $N in expression position parses as Expr::Placeholder(N).
9007        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
9008        let Statement::Select(sel) = s else { panic!() };
9009        assert!(matches!(
9010            sel.items[0],
9011            SelectItem::Expr {
9012                expr: Expr::Placeholder(1),
9013                alias: None
9014            }
9015        ));
9016        // $2 + 1
9017        let SelectItem::Expr {
9018            expr: Expr::Binary { lhs, rhs, .. },
9019            ..
9020        } = &sel.items[1]
9021        else {
9022            panic!()
9023        };
9024        assert!(matches!(**lhs, Expr::Placeholder(2)));
9025        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
9026        // WHERE x = $3
9027        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
9028            panic!()
9029        };
9030        assert!(matches!(**rhs, Expr::Placeholder(3)));
9031    }
9032
9033    #[test]
9034    fn parser_rejects_dollar_zero() {
9035        // $0 is not valid in PG; the lexer rejects it.
9036        assert!(parse_statement("SELECT $0").is_err());
9037    }
9038
9039    #[test]
9040    fn placeholder_display_roundtrips() {
9041        // The Display impl must produce text that re-lexes to the
9042        // same Placeholder token.
9043        let s = parse("SELECT $42 FROM t");
9044        let printed = s.to_string();
9045        assert!(printed.contains("$42"));
9046        let again = parse(&printed);
9047        assert_eq!(s, again);
9048    }
9049
9050    #[test]
9051    fn alter_index_rebuild_bare() {
9052        use crate::ast::{AlterIndexTarget, Statement};
9053        let s = parse("ALTER INDEX my_idx REBUILD");
9054        let Statement::AlterIndex(a) = s else {
9055            panic!("expected AlterIndex, got {s:?}")
9056        };
9057        assert_eq!(a.name, "my_idx");
9058        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
9059    }
9060
9061    #[test]
9062    fn alter_index_rebuild_with_encoding() {
9063        use crate::ast::{AlterIndexTarget, Statement};
9064        for (sql, want) in [
9065            (
9066                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
9067                VecEncoding::F32,
9068            ),
9069            (
9070                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
9071                VecEncoding::Sq8,
9072            ),
9073            (
9074                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9075                VecEncoding::F16,
9076            ),
9077        ] {
9078            let s = parse(sql);
9079            let Statement::AlterIndex(a) = s else {
9080                panic!("{sql}: expected AlterIndex")
9081            };
9082            assert_eq!(a.name, "my_idx");
9083            assert_eq!(
9084                a.target,
9085                AlterIndexTarget::Rebuild {
9086                    encoding: Some(want)
9087                },
9088                "{sql}"
9089            );
9090        }
9091    }
9092
9093    #[test]
9094    fn alter_index_rebuild_unknown_encoding_errors() {
9095        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
9096        assert!(
9097            err.message.contains("unknown vector encoding"),
9098            "got: {}",
9099            err.message
9100        );
9101    }
9102
9103    #[test]
9104    fn alter_index_rebuild_display_roundtrips() {
9105        for (input, want) in [
9106            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
9107            (
9108                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9109                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9110            ),
9111            (
9112                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9113                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9114            ),
9115        ] {
9116            let s = parse(input);
9117            assert_eq!(s.to_string(), want);
9118        }
9119    }
9120
9121    #[test]
9122    fn create_table_unknown_type_errors() {
9123        // v4.9: JSON is now real; pick an actually unsupported keyword
9124        // (XML never landed and isn't planned).
9125        let err = parse_statement("CREATE TABLE x (a xml)").unwrap_err();
9126        assert!(err.message.contains("unsupported column type"));
9127    }
9128
9129    #[test]
9130    fn create_table_missing_table_keyword_errors() {
9131        assert!(parse_statement("CREATE x (a INT)").is_err());
9132    }
9133
9134    #[test]
9135    fn insert_single_value() {
9136        let s = parse("INSERT INTO foo VALUES (42)");
9137        let Statement::Insert(i) = s else {
9138            panic!("expected Insert")
9139        };
9140        assert_eq!(i.table, "foo");
9141        assert_eq!(i.rows.len(), 1);
9142        assert_eq!(i.rows[0].len(), 1);
9143        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
9144    }
9145
9146    #[test]
9147    fn insert_multi_value_with_mixed_literals() {
9148        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
9149        let Statement::Insert(i) = s else { panic!() };
9150        assert_eq!(i.rows.len(), 1);
9151        assert_eq!(i.rows[0].len(), 5);
9152    }
9153
9154    #[test]
9155    fn insert_missing_into_errors() {
9156        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
9157    }
9158
9159    #[test]
9160    fn create_table_round_trip() {
9161        let original =
9162            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
9163        let text = original.to_string();
9164        let again = parse_statement(&text).expect("re-parse");
9165        assert_eq!(original, again);
9166    }
9167
9168    #[test]
9169    fn insert_round_trip_with_negation_and_string() {
9170        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
9171        let text = original.to_string();
9172        let again = parse_statement(&text).expect("re-parse");
9173        assert_eq!(original, again);
9174    }
9175
9176    #[test]
9177    fn unknown_keyword_at_statement_start_errors() {
9178        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
9179        // the top-level dispatch still has no branch to take.
9180        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
9181        assert!(err.message.contains("expected SELECT"));
9182    }
9183
9184    // --- v0.8 CREATE INDEX --------------------------------------------------
9185
9186    #[test]
9187    fn create_index_basic() {
9188        let s = parse("CREATE INDEX idx_id ON users (id)");
9189        let Statement::CreateIndex(c) = s else {
9190            panic!("expected CreateIndex")
9191        };
9192        assert_eq!(c.name, "idx_id");
9193        assert_eq!(c.table, "users");
9194        assert_eq!(c.column, "id");
9195    }
9196
9197    #[test]
9198    fn create_index_missing_on_errors() {
9199        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
9200    }
9201
9202    #[test]
9203    fn create_index_missing_paren_errors() {
9204        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
9205    }
9206
9207    #[test]
9208    fn create_index_round_trip() {
9209        let original = parse("CREATE INDEX by_name ON users (name)");
9210        let again = parse_statement(&original.to_string()).unwrap();
9211        assert_eq!(original, again);
9212    }
9213
9214    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
9215
9216    #[test]
9217    fn create_unique_index_basic() {
9218        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
9219        let Statement::CreateIndex(c) = s else {
9220            panic!("expected CreateIndex");
9221        };
9222        assert!(c.is_unique);
9223        assert_eq!(c.column, "a");
9224        assert!(c.partial_predicate.is_none());
9225    }
9226
9227    #[test]
9228    fn create_unique_index_partial() {
9229        // mailrs's email_templates "one default per user" shape.
9230        let s = parse(
9231            "CREATE UNIQUE INDEX idx_email_templates_user_default \
9232             ON email_templates (user_address) WHERE is_default = true",
9233        );
9234        let Statement::CreateIndex(c) = s else {
9235            panic!("expected CreateIndex");
9236        };
9237        assert!(c.is_unique);
9238        assert_eq!(c.table, "email_templates");
9239        assert_eq!(c.column, "user_address");
9240        assert!(c.partial_predicate.is_some());
9241    }
9242
9243    #[test]
9244    fn create_unique_index_composite_with_predicate() {
9245        // mailrs's calendar_events instance: composite columns.
9246        let s = parse(
9247            "CREATE UNIQUE INDEX uq_calendar_events_instance \
9248             ON calendar_events (calendar_id, uid, recurrence_id) \
9249             WHERE recurrence_id IS NOT NULL",
9250        );
9251        let Statement::CreateIndex(c) = s else {
9252            panic!("expected CreateIndex");
9253        };
9254        assert!(c.is_unique);
9255        assert_eq!(c.column, "calendar_id");
9256        assert_eq!(
9257            c.extra_columns,
9258            vec!["uid".to_string(), "recurrence_id".to_string()]
9259        );
9260        assert!(c.partial_predicate.is_some());
9261    }
9262
9263    #[test]
9264    fn create_unique_index_using_btree_ok() {
9265        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
9266        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
9267    }
9268
9269    #[test]
9270    fn create_unique_index_using_hnsw_rejected() {
9271        let err =
9272            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
9273        assert!(err.message.contains("UNIQUE"), "{}", err.message);
9274    }
9275
9276    #[test]
9277    fn create_unique_index_round_trip() {
9278        let original = parse(
9279            "CREATE UNIQUE INDEX uq_calendar_events_master \
9280             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
9281        );
9282        let again = parse_statement(&original.to_string()).unwrap();
9283        assert_eq!(original, again);
9284    }
9285
9286    #[test]
9287    fn create_unique_without_index_errors() {
9288        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
9289        assert!(err.message.contains("INDEX"), "{}", err.message);
9290    }
9291
9292    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
9293
9294    #[test]
9295    fn create_table_bytea_column() {
9296        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
9297        let Statement::CreateTable(c) = s else {
9298            panic!("expected CreateTable");
9299        };
9300        assert_eq!(c.columns.len(), 2);
9301        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
9302        assert!(!c.columns[1].nullable);
9303    }
9304
9305    #[test]
9306    fn create_table_bytes_alias_column() {
9307        let s = parse("CREATE TABLE t (blob BYTES)");
9308        let Statement::CreateTable(c) = s else {
9309            panic!("expected CreateTable");
9310        };
9311        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
9312    }
9313
9314    #[test]
9315    fn bytea_round_trip_display() {
9316        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
9317        let again = parse_statement(&original.to_string()).unwrap();
9318        assert_eq!(original, again);
9319    }
9320
9321    // --- v0.9 transactions -------------------------------------------------
9322
9323    #[test]
9324    fn begin_commit_rollback_parse_as_unit_variants() {
9325        assert_eq!(parse("BEGIN"), Statement::Begin);
9326        assert_eq!(parse("COMMIT"), Statement::Commit);
9327        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
9328        // Trailing semicolons accepted too.
9329        assert_eq!(parse("BEGIN;"), Statement::Begin);
9330    }
9331
9332    // --- v1.2: pgvector distance ops + ::vector cast --------------------
9333
9334    #[test]
9335    fn inner_product_binop_parses() {
9336        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
9337        let Statement::Select(s) = s else { panic!() };
9338        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9339            panic!()
9340        };
9341        assert!(matches!(
9342            expr,
9343            Expr::Binary {
9344                op: BinOp::InnerProduct,
9345                ..
9346            }
9347        ));
9348    }
9349
9350    #[test]
9351    fn cosine_distance_binop_parses() {
9352        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
9353        let Statement::Select(s) = s else { panic!() };
9354        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9355            panic!()
9356        };
9357        assert!(matches!(
9358            expr,
9359            Expr::Binary {
9360                op: BinOp::CosineDistance,
9361                ..
9362            }
9363        ));
9364    }
9365
9366    #[test]
9367    fn vector_cast_postfix_wraps_string_literal() {
9368        let s = parse("SELECT '[1,2,3]'::vector FROM t");
9369        let Statement::Select(s) = s else { panic!() };
9370        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9371            panic!()
9372        };
9373        assert!(matches!(
9374            expr,
9375            Expr::Cast {
9376                target: CastTarget::Vector,
9377                ..
9378            }
9379        ));
9380    }
9381
9382    #[test]
9383    fn unsupported_cast_target_errors() {
9384        // `::numeric` isn't in the v1.3 cast target set.
9385        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
9386        assert!(err.message.contains("unsupported cast target"));
9387    }
9388
9389    #[test]
9390    fn tx_statements_round_trip() {
9391        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
9392            let original = parse(q);
9393            let again = parse_statement(&original.to_string()).unwrap();
9394            assert_eq!(original, again);
9395        }
9396    }
9397
9398    #[test]
9399    fn interval_text_parsing_units() {
9400        // Single unit.
9401        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
9402        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
9403        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
9404        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
9405        // Compound spans accumulate.
9406        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
9407        assert_eq!(
9408            parse_interval_text("1 day 2 hours"),
9409            Some((0, 86_400_000_000 + 7_200_000_000))
9410        );
9411        // Negative numbers carry through.
9412        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
9413        // Bad shapes return None.
9414        assert_eq!(parse_interval_text(""), None);
9415        assert_eq!(parse_interval_text("garbage"), None);
9416        assert_eq!(parse_interval_text("1 fortnight"), None);
9417        assert_eq!(parse_interval_text("1"), None);
9418    }
9419
9420    #[test]
9421    fn interval_literal_roundtrips_via_display() {
9422        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
9423        let s = parsed.to_string();
9424        // Display preserves the original text verbatim.
9425        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
9426        // And re-parsing yields a structurally equal statement.
9427        let again = parse_statement(&s).unwrap();
9428        assert_eq!(parsed, again);
9429    }
9430
9431    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
9432
9433    #[test]
9434    fn parser_recognises_create_publication_bare() {
9435        let s = parse("CREATE PUBLICATION pub_a");
9436        let Statement::CreatePublication(p) = s else {
9437            panic!("expected CreatePublication, got {s:?}")
9438        };
9439        assert_eq!(p.name, "pub_a");
9440        assert_eq!(p.scope, PublicationScope::AllTables);
9441    }
9442
9443    #[test]
9444    fn parser_recognises_create_publication_for_all_tables() {
9445        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
9446        let Statement::CreatePublication(p) = s else {
9447            panic!("expected CreatePublication, got {s:?}")
9448        };
9449        assert_eq!(p.name, "pub_a");
9450        assert_eq!(p.scope, PublicationScope::AllTables);
9451    }
9452
9453    #[test]
9454    fn parser_recognises_drop_publication() {
9455        let s = parse("DROP PUBLICATION pub_a");
9456        let Statement::DropPublication(name) = s else {
9457            panic!("expected DropPublication, got {s:?}")
9458        };
9459        assert_eq!(name, "pub_a");
9460    }
9461
9462    #[test]
9463    fn parser_recognises_for_table_list() {
9464        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
9465        let Statement::CreatePublication(p) = s else {
9466            panic!("expected CreatePublication, got {s:?}")
9467        };
9468        assert_eq!(p.name, "pub_a");
9469        let PublicationScope::ForTables(ts) = p.scope else {
9470            panic!("expected ForTables scope")
9471        };
9472        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
9473    }
9474
9475    #[test]
9476    fn parser_recognises_for_tables_plural() {
9477        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
9478        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
9479        let Statement::CreatePublication(p) = s else {
9480            panic!("expected CreatePublication, got {s:?}")
9481        };
9482        let PublicationScope::ForTables(ts) = p.scope else {
9483            panic!("expected ForTables")
9484        };
9485        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9486    }
9487
9488    #[test]
9489    fn parser_recognises_for_all_tables_except_list() {
9490        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
9491        let Statement::CreatePublication(p) = s else {
9492            panic!()
9493        };
9494        let PublicationScope::AllTablesExcept(ts) = p.scope else {
9495            panic!("expected AllTablesExcept")
9496        };
9497        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9498    }
9499
9500    #[test]
9501    fn parser_rejects_for_table_with_empty_list() {
9502        // `FOR TABLE` with nothing after is a parse error.
9503        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
9504            .expect_err("must error on empty list");
9505        // No specific message asserted — the call falls through to
9506        // expect_ident_like which yields "expected identifier, got …".
9507        assert!(!err.message.is_empty());
9508    }
9509
9510    #[test]
9511    fn parser_recognises_show_publications() {
9512        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
9513        // bare ident in this position, NOT a reserved keyword.
9514        let s = parse("SHOW PUBLICATIONS");
9515        assert!(matches!(s, Statement::ShowPublications));
9516    }
9517
9518    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
9519
9520    #[test]
9521    fn parser_recognises_create_subscription_single_publication() {
9522        let s = parse(
9523            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
9524        );
9525        let Statement::CreateSubscription(c) = s else {
9526            panic!("expected CreateSubscription, got {s:?}")
9527        };
9528        assert_eq!(c.name, "sub_a");
9529        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
9530        assert_eq!(c.publications, alloc::vec!["pub_a"]);
9531    }
9532
9533    #[test]
9534    fn parser_recognises_create_subscription_multi_publication() {
9535        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
9536        let Statement::CreateSubscription(c) = s else {
9537            panic!()
9538        };
9539        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
9540    }
9541
9542    #[test]
9543    fn parser_rejects_create_subscription_missing_connection() {
9544        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
9545            .expect_err("must error on missing CONNECTION");
9546        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
9547    }
9548
9549    #[test]
9550    fn parser_rejects_create_subscription_missing_publication() {
9551        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
9552            .expect_err("must error on missing PUBLICATION");
9553        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
9554    }
9555
9556    #[test]
9557    fn parser_recognises_drop_subscription() {
9558        let s = parse("DROP SUBSCRIPTION sub_a");
9559        let Statement::DropSubscription(name) = s else {
9560            panic!("expected DropSubscription, got {s:?}")
9561        };
9562        assert_eq!(name, "sub_a");
9563    }
9564
9565    #[test]
9566    fn parser_recognises_show_subscriptions() {
9567        let s = parse("SHOW SUBSCRIPTIONS");
9568        assert!(matches!(s, Statement::ShowSubscriptions));
9569    }
9570
9571    #[test]
9572    fn parser_recognises_wait_for_wal_position_no_timeout() {
9573        let s = parse("WAIT FOR WAL POSITION 12345");
9574        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9575            panic!("expected WaitForWalPosition, got {s:?}")
9576        };
9577        assert_eq!(pos, 12345);
9578        assert!(timeout_ms.is_none());
9579    }
9580
9581    #[test]
9582    fn parser_recognises_wait_for_wal_position_with_timeout() {
9583        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
9584        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9585            panic!()
9586        };
9587        assert_eq!(pos, 67890);
9588        assert_eq!(timeout_ms, Some(5000));
9589    }
9590
9591    #[test]
9592    fn parser_rejects_wait_with_negative_position() {
9593        // The lexer treats `-` as a token; `expect_u64_literal`
9594        // only sees the Integer that follows, so the negative
9595        // arrives as a unary-minus expression at higher levels.
9596        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
9597        // parse error one way or another.
9598        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
9599        assert!(!err.message.is_empty());
9600    }
9601
9602    #[test]
9603    fn parser_recognises_bare_analyze() {
9604        let s = parse("ANALYZE");
9605        assert!(matches!(s, Statement::Analyze(None)));
9606    }
9607
9608    #[test]
9609    fn parser_recognises_analyze_with_table() {
9610        let s = parse("ANALYZE users");
9611        let Statement::Analyze(Some(name)) = s else {
9612            panic!("expected Analyze, got {s:?}")
9613        };
9614        assert_eq!(name, "users");
9615    }
9616
9617    #[test]
9618    fn parser_recognises_analyze_with_quoted_table() {
9619        let s = parse("ANALYZE \"Mixed Case\"");
9620        let Statement::Analyze(Some(name)) = s else {
9621            panic!()
9622        };
9623        assert_eq!(name, "Mixed Case");
9624    }
9625
9626    #[test]
9627    fn parser_rejects_analyze_with_garbage_token() {
9628        let err = parse_statement("ANALYZE 42").expect_err("must error");
9629        assert!(!err.message.is_empty());
9630    }
9631
9632    #[test]
9633    fn analyze_display_roundtrips() {
9634        for sql in ["ANALYZE", "ANALYZE users"] {
9635            let s = parse(sql);
9636            let printed = s.to_string();
9637            let again = parse_statement(&printed)
9638                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9639            assert_eq!(s, again);
9640        }
9641    }
9642
9643    #[test]
9644    fn wait_for_display_roundtrips() {
9645        for sql in [
9646            "WAIT FOR WAL POSITION 12345",
9647            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
9648        ] {
9649            let s = parse(sql);
9650            let printed = s.to_string();
9651            let again = parse_statement(&printed)
9652                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9653            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9654        }
9655    }
9656
9657    #[test]
9658    fn subscription_ddl_display_roundtrips() {
9659        for sql in [
9660            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
9661            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
9662            "DROP SUBSCRIPTION sub_a",
9663            "SHOW SUBSCRIPTIONS",
9664        ] {
9665            let s = parse(sql);
9666            let printed = s.to_string();
9667            let again = parse_statement(&printed)
9668                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9669            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9670        }
9671    }
9672
9673    #[test]
9674    fn parser_drop_dispatches_user_vs_publication() {
9675        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
9676        // tokenises DROP. Both targets must still parse.
9677        let s = parse("DROP USER 'alice'");
9678        let Statement::DropUser(name) = s else {
9679            panic!("expected DropUser, got {s:?}")
9680        };
9681        assert_eq!(name, "alice");
9682        // And DROP PUBLICATION lands the new variant.
9683        let s = parse("DROP PUBLICATION p1");
9684        assert!(matches!(s, Statement::DropPublication(_)));
9685    }
9686
9687    #[test]
9688    fn publication_ddl_display_roundtrips() {
9689        // Every CREATE PUBLICATION variant must Display → parse →
9690        // same AST. v6.1.3 covers all three scope shapes.
9691        for sql in [
9692            "CREATE PUBLICATION pub_a",
9693            "CREATE PUBLICATION pub_a FOR ALL TABLES",
9694            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
9695            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
9696            "DROP PUBLICATION pub_a",
9697            "SHOW PUBLICATIONS",
9698        ] {
9699            let s = parse(sql);
9700            let printed = s.to_string();
9701            let again = parse_statement(&printed)
9702                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9703            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9704        }
9705    }
9706
9707    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
9708
9709    #[test]
9710    fn create_function_returns_trigger_plpgsql_minimal() {
9711        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
9712        let s = parse(sql);
9713        let Statement::CreateFunction(f) = s else {
9714            panic!("expected CreateFunction");
9715        };
9716        assert_eq!(f.name, "noop");
9717        assert!(!f.or_replace);
9718        assert!(f.args.is_empty());
9719        assert!(matches!(f.returns, FunctionReturn::Trigger));
9720        assert_eq!(f.language, "plpgsql");
9721        let FunctionBody::PlPgSql(block) = f.body else {
9722            panic!("expected PlPgSql body");
9723        };
9724        assert_eq!(block.statements.len(), 1);
9725        assert!(matches!(
9726            block.statements[0],
9727            PlPgSqlStmt::Return(ReturnTarget::New)
9728        ));
9729    }
9730
9731    #[test]
9732    fn create_function_or_replace_with_assignment() {
9733        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
9734        // RETURN NEW.
9735        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
9736BEGIN
9737  NEW.search_vector := to_tsvector('english', NEW.subject);
9738  RETURN NEW;
9739END;
9740$$";
9741        let s = parse(sql);
9742        let Statement::CreateFunction(f) = s else {
9743            panic!("expected CreateFunction");
9744        };
9745        assert!(f.or_replace);
9746        let FunctionBody::PlPgSql(block) = &f.body else {
9747            panic!("expected PlPgSql body");
9748        };
9749        assert_eq!(block.statements.len(), 2);
9750        // First statement: NEW.search_vector := to_tsvector(...)
9751        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
9752            panic!("expected Assign as first stmt");
9753        };
9754        match target {
9755            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
9756            other => panic!("expected NEW.col, got {other:?}"),
9757        }
9758        // Second statement: RETURN NEW
9759        assert!(matches!(
9760            block.statements[1],
9761            PlPgSqlStmt::Return(ReturnTarget::New)
9762        ));
9763    }
9764
9765    #[test]
9766    fn create_trigger_after_insert_or_update() {
9767        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
9768        let s = parse(sql);
9769        let Statement::CreateTrigger(t) = s else {
9770            panic!("expected CreateTrigger");
9771        };
9772        assert_eq!(t.name, "tg");
9773        assert_eq!(t.table, "messages");
9774        assert_eq!(t.timing, TriggerTiming::After);
9775        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
9776        assert_eq!(t.for_each, TriggerForEach::Row);
9777        assert_eq!(t.function, "update_sv");
9778    }
9779
9780    #[test]
9781    fn create_trigger_before_delete_execute_procedure_alias() {
9782        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
9783        let sql =
9784            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
9785        let s = parse(sql);
9786        let Statement::CreateTrigger(t) = s else {
9787            panic!("expected CreateTrigger");
9788        };
9789        assert_eq!(t.timing, TriggerTiming::Before);
9790        assert_eq!(t.events, vec![TriggerEvent::Delete]);
9791    }
9792
9793    #[test]
9794    fn drop_trigger_if_exists_round_trips() {
9795        // No parser support for DROP TRIGGER yet — added in v7.12.5
9796        // alongside the broader DROP …{IF EXISTS} cleanup. The
9797        // AST + Display impls are in place so we round-trip via
9798        // construction:
9799        let s = Statement::DropTrigger {
9800            name: "tg".into(),
9801            table: "messages".into(),
9802            if_exists: true,
9803        };
9804        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
9805    }
9806
9807    #[test]
9808    fn trigger_ddl_display_roundtrips_through_parser() {
9809        // CREATE TRIGGER + its referenced CREATE FUNCTION must
9810        // Display → parse → same AST (modulo PL/pgSQL body
9811        // formatting which is parser-canonicalised).
9812        for sql in [
9813            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
9814            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
9815        ] {
9816            let s = parse(sql);
9817            let printed = s.to_string();
9818            let again = parse_statement(&printed)
9819                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9820            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9821        }
9822    }
9823}