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        // v7.20 — PG reserves the table-constraint keywords, so a
6139        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6140        // malformed constraint clause (e.g. `UNIQUE a` missing its
6141        // parens), not a column named "unique". Since v7.17's
6142        // unknown-type leniency (`user_type_ref`) such a clause
6143        // would otherwise parse as a column with a user-defined
6144        // type — silently accepting invalid DDL. Quoted
6145        // identifiers ("unique" / `unique`) remain valid names.
6146        if let Token::Ident(s) = self.peek()
6147            && [
6148                "unique",
6149                "primary",
6150                "foreign",
6151                "constraint",
6152                "check",
6153                "references",
6154                "exclude",
6155            ]
6156            .iter()
6157            .any(|kw| s.eq_ignore_ascii_case(kw))
6158        {
6159            return Err(self.err(alloc::format!(
6160                "unexpected reserved keyword '{s}' at start of column definition \
6161                 (malformed table constraint?)"
6162            )));
6163        }
6164        let name = self.expect_ident_like()?;
6165        let (
6166            ty,
6167            implied_auto_increment,
6168            implied_not_null,
6169            user_type_ref,
6170            collation,
6171            is_unsigned,
6172            inline_enum_variants,
6173            inline_set_variants,
6174        ) = self.parse_type_with_implied_flags()?;
6175        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6176        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6177        // each at most once.
6178        let mut default: Option<Expr> = None;
6179        let mut nullable = !implied_not_null;
6180        let mut nullability_seen = implied_not_null;
6181        let mut auto_increment = implied_auto_increment;
6182        let mut is_primary_key = false;
6183        let mut is_unique = false;
6184        let mut check: Option<Expr> = None;
6185        let mut on_update_runtime: Option<Expr> = None;
6186        loop {
6187            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
6188            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
6189            // is accepted today. The "ON" token is an Ident
6190            // (not reserved) — peek before consuming.
6191            if matches!(self.peek(), Token::On)
6192                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
6193            {
6194                self.advance(); // ON
6195                self.advance(); // update
6196                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
6197                let next = self.peek().clone();
6198                match next {
6199                    Token::Ident(s) | Token::QuotedIdent(s)
6200                        if s.eq_ignore_ascii_case("current_timestamp") =>
6201                    {
6202                        self.advance();
6203                        // Optional `(N)` precision.
6204                        if matches!(self.peek(), Token::LParen) {
6205                            self.advance();
6206                            if !matches!(self.peek(), Token::Integer(_)) {
6207                                return Err(self.err(alloc::format!(
6208                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
6209                                    self.peek()
6210                                )));
6211                            }
6212                            self.advance();
6213                            if !matches!(self.peek(), Token::RParen) {
6214                                return Err(self.err(alloc::format!(
6215                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
6216                                    self.peek()
6217                                )));
6218                            }
6219                            self.advance();
6220                        }
6221                        on_update_runtime = Some(Expr::FunctionCall {
6222                            name: "now".into(),
6223                            args: Vec::new(),
6224                        });
6225                        continue;
6226                    }
6227                    other => {
6228                        return Err(self.err(alloc::format!(
6229                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
6230                        )));
6231                    }
6232                }
6233            }
6234            if matches!(self.peek(), Token::Default) {
6235                if default.is_some() {
6236                    return Err(self.err("DEFAULT specified twice".into()));
6237                }
6238                self.advance();
6239                default = Some(self.parse_expr(0)?);
6240                continue;
6241            }
6242            if matches!(self.peek(), Token::Not) {
6243                if nullability_seen {
6244                    return Err(self.err("NOT NULL specified twice".into()));
6245                }
6246                self.advance();
6247                if !matches!(self.peek(), Token::Null) {
6248                    return Err(self.err(format!(
6249                        "expected NULL after NOT in column def, got {:?}",
6250                        self.peek()
6251                    )));
6252                }
6253                self.advance();
6254                nullable = false;
6255                nullability_seen = true;
6256                continue;
6257            }
6258            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
6259            // "this column is nullable" marker (the default in
6260            // standard SQL anyway). mysqldump emits it routinely
6261            // (`col TYPE NULL DEFAULT NULL` for nullable
6262            // timestamps etc). Accept + no-op.
6263            if matches!(self.peek(), Token::Null) {
6264                if nullability_seen && !nullable {
6265                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
6266                }
6267                self.advance();
6268                nullable = true;
6269                nullability_seen = true;
6270                continue;
6271            }
6272            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
6273            // arrives as a bare Ident. Match either, case-insensitive.
6274            if let Token::Ident(s) = self.peek()
6275                && (s.eq_ignore_ascii_case("auto_increment")
6276                    || s.eq_ignore_ascii_case("autoincrement"))
6277            {
6278                if auto_increment {
6279                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
6280                }
6281                self.advance();
6282                auto_increment = true;
6283                continue;
6284            }
6285            // v7.9.13 — inline `PRIMARY KEY` column constraint
6286            // (mailrs F1). Implies `NOT NULL`. The engine creates
6287            // a BTree index for the PK column at CREATE TABLE time
6288            // so FK parent-side index lookups resolve.
6289            if let Token::Ident(s) = self.peek()
6290                && s.eq_ignore_ascii_case("primary")
6291            {
6292                if is_primary_key {
6293                    return Err(self.err("PRIMARY KEY specified twice".into()));
6294                }
6295                // Peek-ahead for the required `KEY` token.
6296                let next = self.tokens.get(self.pos + 1);
6297                let next_is_key = matches!(
6298                    next,
6299                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
6300                );
6301                if !next_is_key {
6302                    return Err(self.err(format!(
6303                        "expected KEY after PRIMARY in column def, got {:?}",
6304                        next
6305                    )));
6306                }
6307                self.advance(); // PRIMARY
6308                self.advance(); // KEY
6309                is_primary_key = true;
6310                if nullability_seen && nullable {
6311                    return Err(self.err(
6312                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
6313                    ));
6314                }
6315                nullable = false;
6316                nullability_seen = true;
6317                continue;
6318            }
6319            // v7.13.0 — inline `UNIQUE` column constraint
6320            // (mailrs round-5 G2). Fold into a single-column
6321            // table-level UNIQUE at CREATE TABLE post-process time.
6322            if let Token::Ident(s) = self.peek()
6323                && s.eq_ignore_ascii_case("unique")
6324            {
6325                if is_unique {
6326                    return Err(self.err("UNIQUE specified twice".into()));
6327                }
6328                self.advance();
6329                is_unique = true;
6330                continue;
6331            }
6332            // v7.13.0 — inline `CHECK (<expr>)` column constraint
6333            // (mailrs round-5 G3). PG semantics: column-level
6334            // CHECK is equivalent to a table-level CHECK. Multiple
6335            // inline CHECKs on the same column AND together.
6336            if let Token::Ident(s) = self.peek()
6337                && s.eq_ignore_ascii_case("check")
6338            {
6339                self.advance();
6340                if !matches!(self.peek(), Token::LParen) {
6341                    return Err(self.err(alloc::format!(
6342                        "expected '(' after CHECK in column def, got {:?}",
6343                        self.peek()
6344                    )));
6345                }
6346                self.advance();
6347                let pred = self.parse_expr(0)?;
6348                if !matches!(self.peek(), Token::RParen) {
6349                    return Err(self.err(alloc::format!(
6350                        "expected ')' to close CHECK predicate, got {:?}",
6351                        self.peek()
6352                    )));
6353                }
6354                self.advance();
6355                check = Some(match check.take() {
6356                    Some(prev) => Expr::Binary {
6357                        op: BinOp::And,
6358                        lhs: Box::new(prev),
6359                        rhs: Box::new(pred),
6360                    },
6361                    None => pred,
6362                });
6363                continue;
6364            }
6365            break;
6366        }
6367        Ok(ColumnDef {
6368            name,
6369            ty,
6370            nullable,
6371            default,
6372            auto_increment,
6373            is_primary_key,
6374            is_unique,
6375            check,
6376            user_type_ref,
6377            on_update_runtime,
6378            collation,
6379            is_unsigned,
6380            inline_enum_variants,
6381            inline_set_variants,
6382        })
6383    }
6384
6385    /// `NUMERIC` may appear without parameters, with one (precision
6386    /// only, scale=0), or with both. Returns `(precision, scale)` with
6387    /// 0 = unspecified for the bare form.
6388    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
6389        if !matches!(self.peek(), Token::LParen) {
6390            // Bare `NUMERIC` — PG treats this as "unlimited precision";
6391            // we surface it as precision=0 to mean "unconstrained" so
6392            // the engine doesn't need a separate variant.
6393            return Ok((0, 0));
6394        }
6395        self.advance();
6396        let precision = match self.advance() {
6397            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
6398            other => {
6399                return Err(ParseError {
6400                    message: format!(
6401                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
6402                    ),
6403                    token_pos: self.pos.saturating_sub(1),
6404                });
6405            }
6406        };
6407        let scale = if matches!(self.peek(), Token::Comma) {
6408            self.advance();
6409            match self.advance() {
6410                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
6411                    u8::try_from(n).expect("range-checked")
6412                }
6413                other => {
6414                    return Err(ParseError {
6415                        message: format!(
6416                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
6417                        ),
6418                        token_pos: self.pos.saturating_sub(1),
6419                    });
6420                }
6421            }
6422        } else {
6423            0
6424        };
6425        if !matches!(self.peek(), Token::RParen) {
6426            return Err(self.err(format!(
6427                "expected ')' to close NUMERIC params, got {:?}",
6428                self.peek()
6429            )));
6430        }
6431        self.advance();
6432        Ok((precision, scale))
6433    }
6434
6435    /// Parse `(N)` where `N` is a positive integer literal — used by the
6436    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
6437    /// for the error message.
6438    /// v6.0.1: parse the optional `USING <encoding>` clause that
6439    /// follows `VECTOR(N)` in a column definition. Missing clause
6440    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
6441    /// ident → `ParseError` listing the encodings recognised today.
6442    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
6443        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6444            return Ok(VecEncoding::F32);
6445        }
6446        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
6447        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
6448        // consume the token when the very next token is a known
6449        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
6450        // `USING` for the caller — it's the rewrite-expression form.
6451        let n1 = self.tokens.get(self.pos + 1);
6452        let next_is_encoding = matches!(
6453            n1,
6454            Some(Token::Ident(s))
6455                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
6456        );
6457        if !next_is_encoding {
6458            return Ok(VecEncoding::F32);
6459        }
6460        self.advance();
6461        let enc_ident = match self.advance() {
6462            Token::Ident(s) => s,
6463            other => {
6464                return Err(self.err(format!(
6465                    "expected vector encoding after USING, got {other:?}"
6466                )));
6467            }
6468        };
6469        match enc_ident.to_ascii_lowercase().as_str() {
6470            "sq8" => Ok(VecEncoding::Sq8),
6471            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
6472            // binary16 per-element storage.
6473            "half" => Ok(VecEncoding::F16),
6474            other => Err(self.err(format!(
6475                "unknown vector encoding {other:?}; supported: SQ8, HALF"
6476            ))),
6477        }
6478    }
6479
6480    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
6481    /// without consuming it. Returns `Some(N)` when the next
6482    /// tokens are `( <int> )`; None otherwise. Used by the
6483    /// TINYINT classifier to decide whether to map to Bool or
6484    /// SmallInt.
6485    fn peek_optional_paren_size_value(&self) -> Option<i64> {
6486        if !matches!(self.peek(), Token::LParen) {
6487            return None;
6488        }
6489        let next = self.tokens.get(self.pos + 1)?;
6490        let n = match next {
6491            Token::Integer(n) => *n,
6492            _ => return None,
6493        };
6494        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
6495            return None;
6496        }
6497        Some(n)
6498    }
6499
6500    /// v7.14.0 — consume an optional MySQL display-width
6501    /// parenthesised number after an integer type, returning
6502    /// nothing. `TINYINT(1)` etc.
6503    fn consume_optional_paren_size(&mut self) {
6504        if !matches!(self.peek(), Token::LParen) {
6505            return;
6506        }
6507        self.advance();
6508        // Skip until matching RParen (allow nested or any tokens).
6509        let mut depth = 1usize;
6510        while depth > 0 {
6511            match self.peek() {
6512                Token::LParen => depth += 1,
6513                Token::RParen => depth -= 1,
6514                Token::Eof => return,
6515                _ => {}
6516            }
6517            self.advance();
6518        }
6519    }
6520
6521    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
6522        if !matches!(self.peek(), Token::LParen) {
6523            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
6524        }
6525        self.advance();
6526        let n = match self.advance() {
6527            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
6528                message: format!("{label} size too large: {n}"),
6529                token_pos: self.pos.saturating_sub(1),
6530            })?,
6531            other => {
6532                return Err(ParseError {
6533                    message: format!("expected positive integer {label} size, got {other:?}"),
6534                    token_pos: self.pos.saturating_sub(1),
6535                });
6536            }
6537        };
6538        if !matches!(self.peek(), Token::RParen) {
6539            return Err(self.err(format!(
6540                "expected ')' after {label} size, got {:?}",
6541                self.peek()
6542            )));
6543        }
6544        self.advance();
6545        Ok(n)
6546    }
6547
6548    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
6549        debug_assert!(matches!(self.peek(), Token::Insert));
6550        self.advance();
6551        if !matches!(self.peek(), Token::Into) {
6552            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
6553        }
6554        self.advance();
6555        let table = self.expect_ident_like()?;
6556        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
6557        let columns = if matches!(self.peek(), Token::LParen) {
6558            self.advance();
6559            let mut names = Vec::new();
6560            loop {
6561                names.push(self.expect_ident_like()?);
6562                match self.peek() {
6563                    Token::Comma => {
6564                        self.advance();
6565                    }
6566                    Token::RParen => {
6567                        self.advance();
6568                        break;
6569                    }
6570                    other => {
6571                        return Err(self.err(format!(
6572                            "expected ',' or ')' in INSERT column list, got {other:?}"
6573                        )));
6574                    }
6575                }
6576            }
6577            Some(names)
6578        } else {
6579            None
6580        };
6581        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6582        // round-5 G4). Dispatch on VALUES vs SELECT.
6583        if matches!(self.peek(), Token::Select) {
6584            let select_stmt = match self.parse_select_stmt()? {
6585                Statement::Select(s) => s,
6586                other => {
6587                    return Err(self.err(alloc::format!(
6588                        "expected SELECT after INSERT INTO ... target, got {other:?}"
6589                    )));
6590                }
6591            };
6592            let on_conflict = self.parse_optional_on_conflict()?;
6593            let returning = self.parse_optional_returning()?;
6594            return Ok(Statement::Insert(InsertStatement {
6595                table,
6596                columns,
6597                rows: Vec::new(),
6598                select_source: Some(Box::new(select_stmt)),
6599                on_conflict,
6600                returning,
6601            }));
6602        }
6603        if !matches!(self.peek(), Token::Values) {
6604            return Err(self.err(format!(
6605                "expected VALUES or SELECT after table name, got {:?}",
6606                self.peek()
6607            )));
6608        }
6609        self.advance();
6610        if !matches!(self.peek(), Token::LParen) {
6611            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
6612        }
6613        let mut rows = Vec::new();
6614        loop {
6615            // Each iteration consumes one `(expr, expr, …)` tuple.
6616            if !matches!(self.peek(), Token::LParen) {
6617                return Err(self.err(format!(
6618                    "expected '(' for next VALUES tuple, got {:?}",
6619                    self.peek()
6620                )));
6621            }
6622            self.advance();
6623            let mut tuple = Vec::new();
6624            loop {
6625                tuple.push(self.parse_expr(0)?);
6626                match self.peek() {
6627                    Token::Comma => {
6628                        self.advance();
6629                    }
6630                    Token::RParen => {
6631                        self.advance();
6632                        break;
6633                    }
6634                    other => {
6635                        return Err(self.err(format!(
6636                            "expected ',' or ')' in VALUES tuple, got {other:?}"
6637                        )));
6638                    }
6639                }
6640            }
6641            if tuple.is_empty() {
6642                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
6643            }
6644            rows.push(tuple);
6645            // Continue with comma-separated tuples.
6646            if matches!(self.peek(), Token::Comma) {
6647                self.advance();
6648            } else {
6649                break;
6650            }
6651        }
6652        let on_conflict = self.parse_optional_on_conflict()?;
6653        let returning = self.parse_optional_returning()?;
6654        Ok(Statement::Insert(InsertStatement {
6655            table,
6656            columns,
6657            rows,
6658            select_source: None,
6659            on_conflict,
6660            returning,
6661        }))
6662    }
6663
6664    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
6665    /// clause sitting between the INSERT body and the trailing
6666    /// RETURNING. All keywords come in as bare idents; `ON` is
6667    /// a reserved Token though.
6668    fn parse_optional_on_conflict(
6669        &mut self,
6670    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
6671        if !matches!(self.peek(), Token::On) {
6672            return Ok(None);
6673        }
6674        // Peek further: we want exactly "ON CONFLICT ...". If the
6675        // next ident isn't "conflict", let some other parser handle.
6676        let next_is_conflict = matches!(
6677            self.tokens.get(self.pos + 1),
6678            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
6679        );
6680        if !next_is_conflict {
6681            return Ok(None);
6682        }
6683        self.advance(); // ON
6684        self.advance(); // CONFLICT
6685        // Optional `(col [, col]*)` target list.
6686        let mut target_columns: Vec<String> = Vec::new();
6687        if matches!(self.peek(), Token::LParen) {
6688            self.advance();
6689            loop {
6690                target_columns.push(self.expect_ident_like()?);
6691                match self.peek() {
6692                    Token::Comma => {
6693                        self.advance();
6694                    }
6695                    Token::RParen => {
6696                        self.advance();
6697                        break;
6698                    }
6699                    other => {
6700                        return Err(self.err(alloc::format!(
6701                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
6702                        )));
6703                    }
6704                }
6705            }
6706        }
6707        // Required `DO`.
6708        match self.advance() {
6709            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
6710            other => {
6711                return Err(self.err(alloc::format!(
6712                    "expected DO after ON CONFLICT [(…)], got {other:?}"
6713                )));
6714            }
6715        }
6716        // Action: NOTHING | UPDATE SET …
6717        let action = match self.advance() {
6718            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
6719                crate::ast::OnConflictAction::Nothing
6720            }
6721            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
6722                self.parse_on_conflict_update_action()?
6723            }
6724            other => {
6725                return Err(self.err(alloc::format!(
6726                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
6727                )));
6728            }
6729        };
6730        Ok(Some(crate::ast::OnConflictClause {
6731            target_columns,
6732            action,
6733        }))
6734    }
6735
6736    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
6737    /// `SET col = expr [, …] [WHERE cond]`. Caller already
6738    /// consumed `UPDATE`.
6739    fn parse_on_conflict_update_action(
6740        &mut self,
6741    ) -> Result<crate::ast::OnConflictAction, ParseError> {
6742        // `SET`
6743        match self.advance() {
6744            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
6745            other => {
6746                return Err(self.err(alloc::format!(
6747                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
6748                )));
6749            }
6750        }
6751        let mut assignments: Vec<(String, Expr)> = Vec::new();
6752        loop {
6753            let col = self.expect_ident_like()?;
6754            if !matches!(self.peek(), Token::Eq) {
6755                return Err(self.err(alloc::format!(
6756                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
6757                    self.peek()
6758                )));
6759            }
6760            self.advance();
6761            let value = self.parse_expr(0)?;
6762            assignments.push((col, value));
6763            if matches!(self.peek(), Token::Comma) {
6764                self.advance();
6765                continue;
6766            }
6767            break;
6768        }
6769        let where_ = if matches!(self.peek(), Token::Where) {
6770            self.advance();
6771            Some(self.parse_expr(0)?)
6772        } else {
6773            None
6774        };
6775        Ok(crate::ast::OnConflictAction::Update {
6776            assignments,
6777            where_,
6778        })
6779    }
6780
6781    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
6782        let mut items = Vec::new();
6783        loop {
6784            items.push(self.parse_select_item()?);
6785            if matches!(self.peek(), Token::Comma) {
6786                self.advance();
6787            } else {
6788                break;
6789            }
6790        }
6791        Ok(items)
6792    }
6793
6794    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
6795        if matches!(self.peek(), Token::Star) {
6796            self.advance();
6797            return Ok(SelectItem::Wildcard);
6798        }
6799        let expr = self.parse_expr(0)?;
6800        let alias = self.parse_optional_alias();
6801        Ok(SelectItem::Expr { expr, alias })
6802    }
6803
6804    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
6805        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
6806        // table. Detect at the head so it claims precedence over
6807        // every other table-ref shape (unnest / generate_series /
6808        // bare ident); the lateral subquery itself follows the
6809        // regular SELECT grammar.
6810        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
6811            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6812        {
6813            self.advance(); // LATERAL
6814            self.advance(); // (
6815            // Parse the inner SELECT.
6816            let inner = match self.parse_one_statement()? {
6817                Statement::Select(s) => s,
6818                other => {
6819                    return Err(self.err(alloc::format!(
6820                        "expected SELECT inside LATERAL ( … ), got {other:?}"
6821                    )));
6822                }
6823            };
6824            if !matches!(self.peek(), Token::RParen) {
6825                return Err(self.err(alloc::format!(
6826                    "expected ')' after LATERAL subquery, got {:?}",
6827                    self.peek()
6828                )));
6829            }
6830            self.advance();
6831            let alias_ident = self.parse_optional_alias();
6832            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
6833            return Ok(TableRef {
6834                name,
6835                alias: alias_ident,
6836                as_of_segment: None,
6837                unnest_expr: None,
6838                unnest_column_aliases: Vec::new(),
6839                generate_series_args: None,
6840                lateral_subquery: Some(Box::new(inner)),
6841            });
6842        }
6843        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
6844        // source. Detect at the head before the bare-ident fallback;
6845        // unnest is not a reserved token.
6846        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
6847            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6848        {
6849            self.advance(); // unnest
6850            self.advance(); // (
6851            let expr = self.parse_expr(0)?;
6852            if !matches!(self.peek(), Token::RParen) {
6853                return Err(self.err(alloc::format!(
6854                    "expected ')' after unnest() argument, got {:?}",
6855                    self.peek()
6856                )));
6857            }
6858            self.advance();
6859            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
6860            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
6861            return Ok(TableRef {
6862                name,
6863                alias: alias_ident,
6864                as_of_segment: None,
6865                unnest_expr: Some(Box::new(expr)),
6866                unnest_column_aliases,
6867                generate_series_args: None,
6868                lateral_subquery: None,
6869            });
6870        }
6871        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
6872        // [, step])` set-returning source. Same shape as unnest:
6873        // detect at the head, parse the comma-separated arg list,
6874        // dispatch downstream through the engine's set-returning
6875        // path. Supports integer triplets (mailrs's `WITH row_no AS
6876        // (SELECT * FROM generate_series(1, N))` pattern) and
6877        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
6878        // date-range iteration pattern, which pre-3.10 had no
6879        // direct equivalent in SPG).
6880        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
6881            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
6882        {
6883            self.advance(); // generate_series
6884            self.advance(); // (
6885            let mut args: Vec<Expr> = Vec::new();
6886            loop {
6887                args.push(self.parse_expr(0)?);
6888                if matches!(self.peek(), Token::Comma) {
6889                    self.advance();
6890                    continue;
6891                }
6892                break;
6893            }
6894            if !matches!(self.peek(), Token::RParen) {
6895                return Err(self.err(alloc::format!(
6896                    "expected ')' after generate_series() arguments, got {:?}",
6897                    self.peek()
6898                )));
6899            }
6900            self.advance();
6901            if args.len() < 2 || args.len() > 3 {
6902                return Err(self.err(alloc::format!(
6903                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
6904                    args.len()
6905                )));
6906            }
6907            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
6908            let name = alias_ident
6909                .clone()
6910                .unwrap_or_else(|| "generate_series".to_string());
6911            return Ok(TableRef {
6912                name,
6913                alias: alias_ident,
6914                as_of_segment: None,
6915                unnest_expr: None,
6916                unnest_column_aliases: Vec::new(),
6917                generate_series_args: Some(args),
6918                lateral_subquery: None,
6919            });
6920        }
6921        // v7.16.2 — preserve information_schema / pg_catalog
6922        // qualifiers (mailrs round-10 A.3). The generic
6923        // `expect_ident_like` strip silently drops the schema;
6924        // we want the engine to recognise these PG meta tables
6925        // and synthesise rows from the live catalog. Produce a
6926        // synthetic name (`__spg_info_columns` etc.) so the
6927        // engine's SELECT-side router can dispatch without
6928        // clashing with any user-defined `columns` table.
6929        let name = if let Some(synth) = self.try_peek_meta_qualified() {
6930            synth
6931        } else {
6932            self.expect_ident_like()?
6933        };
6934        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
6935        // time-travel clause. Parse BEFORE the alias so the
6936        // alias can still ride at the tail (`tbl AS OF SEGMENT
6937        // '5' alias`). `AS` is a reserved keyword token, while
6938        // `OF` and `SEGMENT` are bare idents.
6939        let as_of_segment = if matches!(self.peek(), Token::As)
6940            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
6941        {
6942            self.advance(); // AS
6943            self.advance(); // OF
6944            let kw = match self.peek().clone() {
6945                Token::Ident(s) | Token::QuotedIdent(s) => s,
6946                other => {
6947                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
6948                }
6949            };
6950            if !kw.eq_ignore_ascii_case("segment") {
6951                return Err(self.err(format!(
6952                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
6953                )));
6954            }
6955            self.advance();
6956            // Segment id literal — accept either a string or
6957            // integer for operator ergonomics.
6958            let id = match self.advance() {
6959                Token::String(s) => s
6960                    .parse::<u32>()
6961                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
6962                Token::Integer(n) => u32::try_from(n)
6963                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
6964                other => {
6965                    return Err(self.err(format!(
6966                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
6967                    )));
6968                }
6969            };
6970            Some(id)
6971        } else {
6972            None
6973        };
6974        let alias = self.parse_optional_alias();
6975        Ok(TableRef {
6976            name,
6977            alias,
6978            as_of_segment,
6979            unnest_expr: None,
6980            unnest_column_aliases: Vec::new(),
6981            generate_series_args: None,
6982            lateral_subquery: None,
6983        })
6984    }
6985
6986    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
6987    /// but also accepts `AS alias(col [, col, …])` — the
6988    /// PG-standard table-function column-list form. The column
6989    /// list is only honoured when paired with `UNNEST(...)` in
6990    /// the parent; other call sites currently discard it.
6991    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
6992        let alias = self.parse_optional_alias();
6993        if alias.is_none() {
6994            return (None, Vec::new());
6995        }
6996        let mut cols: Vec<String> = Vec::new();
6997        if matches!(self.peek(), Token::LParen) {
6998            self.advance();
6999            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
7000                self.advance();
7001                cols.push(s);
7002                if matches!(self.peek(), Token::Comma) {
7003                    self.advance();
7004                    continue;
7005                }
7006                break;
7007            }
7008            if matches!(self.peek(), Token::RParen) {
7009                self.advance();
7010            }
7011        }
7012        (alias, cols)
7013    }
7014
7015    /// FROM-clause: a primary table reference plus zero-or-more joined
7016    /// peers expressed via either `, <table>` (cross-product, no ON) or
7017    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
7018    /// the join list flat (left-associative nested-loop semantics).
7019    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
7020        let primary = self.parse_table_ref()?;
7021        let mut joins = Vec::new();
7022        loop {
7023            // `, <table>` — cross-product with no ON.
7024            if matches!(self.peek(), Token::Comma) {
7025                self.advance();
7026                let table = self.parse_table_ref()?;
7027                joins.push(FromJoin {
7028                    kind: JoinKind::Cross,
7029                    table,
7030                    on: None,
7031                });
7032                continue;
7033            }
7034            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
7035            // CROSS JOIN, and bare JOIN (defaults to INNER).
7036            let kind =
7037                match self.peek() {
7038                    Token::Inner => {
7039                        self.advance();
7040                        if !matches!(self.peek(), Token::Join) {
7041                            return Err(self
7042                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
7043                        }
7044                        self.advance();
7045                        JoinKind::Inner
7046                    }
7047                    Token::Left => {
7048                        self.advance();
7049                        if matches!(self.peek(), Token::Outer) {
7050                            self.advance();
7051                        }
7052                        if !matches!(self.peek(), Token::Join) {
7053                            return Err(self.err(format!(
7054                                "expected JOIN after LEFT [OUTER], got {:?}",
7055                                self.peek()
7056                            )));
7057                        }
7058                        self.advance();
7059                        JoinKind::Left
7060                    }
7061                    Token::Cross => {
7062                        self.advance();
7063                        if !matches!(self.peek(), Token::Join) {
7064                            return Err(self
7065                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
7066                        }
7067                        self.advance();
7068                        JoinKind::Cross
7069                    }
7070                    Token::Join => {
7071                        self.advance();
7072                        JoinKind::Inner
7073                    }
7074                    _ => break,
7075                };
7076            let table = self.parse_table_ref()?;
7077            let on = if matches!(self.peek(), Token::On) {
7078                self.advance();
7079                Some(self.parse_expr(0)?)
7080            } else if kind == JoinKind::Cross {
7081                None
7082            } else {
7083                return Err(self.err(format!(
7084                    "expected ON after {:?} JOIN, got {:?}",
7085                    kind,
7086                    self.peek()
7087                )));
7088            };
7089            joins.push(FromJoin { kind, table, on });
7090        }
7091        Ok(FromClause { primary, joins })
7092    }
7093
7094    /// Optional alias after an expression or table:
7095    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
7096    /// accepted (PG-style implicit alias). Returns `None` if the next token
7097    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
7098    fn parse_optional_alias(&mut self) -> Option<String> {
7099        if matches!(self.peek(), Token::As) {
7100            self.advance();
7101            // After AS, the next token MUST be an identifier-like — if not,
7102            // we still return None and let the caller surface the error on the
7103            // next expectation. v0.2 keeps the alias path forgiving; the
7104            // corpus tests don't exercise the malformed case.
7105            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
7106                return self.expect_ident_like().ok();
7107            }
7108            return None;
7109        }
7110        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
7111        // grammar reserves a long list of follow-keywords from the
7112        // alias slot. SPG's bareword approximation: skip a small
7113        // set of idents that would otherwise be swallowed as the
7114        // table alias and break trailing clauses like CREATE
7115        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
7116        // CONFLICT WHERE shapes.
7117        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
7118            if is_alias_stopword(s) {
7119                return None;
7120            }
7121            return self.expect_ident_like().ok();
7122        }
7123        None
7124    }
7125
7126    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
7127    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7128        let mut lhs = self.parse_unary()?;
7129        while let Some((op, prec)) = binop_from(self.peek()) {
7130            if prec < min_prec {
7131                break;
7132            }
7133            self.advance();
7134            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
7135            // ANY is a bare ident; ALL is a reserved Token. Both
7136            // require an immediate `(` to disambiguate from
7137            // identifier columns named `any` / `all`.
7138            let any_kind = match self.peek() {
7139                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
7140                    Some(false)
7141                }
7142                Token::Ident(s) | Token::QuotedIdent(s)
7143                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
7144                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
7145                {
7146                    Some(s.eq_ignore_ascii_case("any"))
7147                }
7148                _ => None,
7149            };
7150            if let Some(is_any) = any_kind {
7151                self.advance(); // ident
7152                self.advance(); // (
7153                let arr = self.parse_expr(0)?;
7154                if !matches!(self.peek(), Token::RParen) {
7155                    return Err(self.err(alloc::format!(
7156                        "expected ')' after ANY/ALL argument, got {:?}",
7157                        self.peek()
7158                    )));
7159                }
7160                self.advance();
7161                lhs = Expr::AnyAll {
7162                    expr: Box::new(lhs),
7163                    op,
7164                    array: Box::new(arr),
7165                    is_any,
7166                };
7167                continue;
7168            }
7169            let rhs = self.parse_expr(prec + 1)?;
7170            lhs = Expr::Binary {
7171                lhs: Box::new(lhs),
7172                op,
7173                rhs: Box::new(rhs),
7174            };
7175        }
7176        Ok(lhs)
7177    }
7178
7179    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
7180        match self.peek() {
7181            Token::Not => {
7182                self.advance();
7183                // NOT sits between AND (2) and comparisons (4) — bind everything
7184                // ≥3, which leaves AND/OR outside.
7185                let e = self.parse_expr(3)?;
7186                Ok(Expr::Unary {
7187                    op: UnOp::Not,
7188                    expr: Box::new(e),
7189                })
7190            }
7191            Token::Minus => {
7192                self.advance();
7193                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
7194                // `<->` slotted into 5 and arithmetic shifted up).
7195                let e = self.parse_expr(8)?;
7196                Ok(Expr::Unary {
7197                    op: UnOp::Neg,
7198                    expr: Box::new(e),
7199                })
7200            }
7201            _ => self.parse_atom(),
7202        }
7203    }
7204
7205    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
7206        let tok_pos = self.pos;
7207        match self.advance() {
7208            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
7209            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
7210            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
7211            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
7212            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
7213            Token::Null => Ok(Expr::Literal(Literal::Null)),
7214            // v6.1.1 — `$N` placeholder. The actual Value lookup
7215            // happens in the engine eval path against the prepared-
7216            // statement bind buffer.
7217            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
7218            Token::LParen => {
7219                // v4.10: `(SELECT ...)` in expression position is a
7220                // scalar subquery; otherwise it's a parenthesised
7221                // expression. Peek for SELECT keyword to dispatch.
7222                if matches!(self.peek(), Token::Select) {
7223                    let inner = self.parse_select_stmt()?;
7224                    match self.advance() {
7225                        Token::RParen => {
7226                            let Statement::Select(s) = inner else {
7227                                unreachable!("parse_select_stmt returns Select")
7228                            };
7229                            Ok(Expr::ScalarSubquery(Box::new(s)))
7230                        }
7231                        other => Err(ParseError {
7232                            message: format!("expected ')' after scalar subquery, got {other:?}"),
7233                            token_pos: self.pos.saturating_sub(1),
7234                        }),
7235                    }
7236                } else {
7237                    let e = self.parse_expr(0)?;
7238                    match self.advance() {
7239                        Token::RParen => Ok(e),
7240                        other => Err(ParseError {
7241                            message: format!("expected ')', got {other:?}"),
7242                            token_pos: self.pos.saturating_sub(1),
7243                        }),
7244                    }
7245                }
7246            }
7247            Token::LBracket => self.parse_vector_literal_body(),
7248            Token::Extract => self.parse_extract_atom(),
7249            Token::Interval => self.parse_interval_atom(),
7250            // `LEFT` is a reserved-keyword token because the
7251            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
7252            // When `left` is followed by `(` we're in expression
7253            // position calling the PG `left(string, n)` function;
7254            // rebuild the AST as a regular function call so the
7255            // engine's apply_function dispatch picks it up.
7256            Token::Left if matches!(self.peek(), Token::LParen) => {
7257                self.advance(); // (
7258                let mut args = Vec::new();
7259                if !matches!(self.peek(), Token::RParen) {
7260                    loop {
7261                        args.push(self.parse_expr(0)?);
7262                        match self.peek() {
7263                            Token::Comma => {
7264                                self.advance();
7265                            }
7266                            Token::RParen => break,
7267                            other => {
7268                                return Err(self.err(alloc::format!(
7269                                    "expected ',' or ')' in left() args, got {other:?}"
7270                                )));
7271                            }
7272                        }
7273                    }
7274                }
7275                self.advance(); // )
7276                Ok(Expr::FunctionCall {
7277                    name: "left".into(),
7278                    args,
7279                })
7280            }
7281            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
7282            // token; we match on the bare ident. NOT is a token
7283            // (consumed in the comparison rung), but `EXISTS (...)`
7284            // at the top of an expression starts here.
7285            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
7286                self.parse_exists_atom(false)
7287            }
7288            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
7289            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
7290            // CASE is a bare ident; we dispatch on lowercase match.
7291            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
7292                self.parse_case_atom()
7293            }
7294            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
7295            // is not a reserved token; we match by case-insensitive
7296            // ident. The opening `[` must follow immediately.
7297            Token::Ident(s) | Token::QuotedIdent(s)
7298                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
7299            {
7300                self.advance(); // consume `[`
7301                let mut items: Vec<Expr> = Vec::new();
7302                if !matches!(self.peek(), Token::RBracket) {
7303                    loop {
7304                        items.push(self.parse_expr(0)?);
7305                        match self.peek() {
7306                            Token::Comma => {
7307                                self.advance();
7308                            }
7309                            Token::RBracket => break,
7310                            other => {
7311                                return Err(self.err(alloc::format!(
7312                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
7313                                )));
7314                            }
7315                        }
7316                    }
7317                }
7318                self.advance(); // consume `]`
7319                Ok(Expr::Array(items))
7320            }
7321            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
7322            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
7323            // We special-case before the generic ident dispatch so
7324            // the AGAINST clause never reaches the function-call
7325            // loop (which would mis-read `(cols) AGAINST` as a
7326            // call with no trailing modifier). The shape is
7327            // rewritten to a Boolean OR over per-column
7328            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7329            // term)` so the existing FTS evaluator handles
7330            // semantics — the fulltext-GIN built at CREATE TABLE
7331            // time is currently a "real index that survives dump
7332            // round-trip"; the planner hook that actually uses
7333            // it for posting-list intersection lands in a later
7334            // sub-phase (Phase 2.2b) without touching this surface.
7335            Token::Ident(s) | Token::QuotedIdent(s)
7336                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
7337            {
7338                self.parse_match_against_atom()
7339            }
7340            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
7341            other => Err(ParseError {
7342                message: format!("unexpected token {other:?} in expression"),
7343                token_pos: tok_pos,
7344            }),
7345        }
7346        // After parsing the atom, fold any postfix `::vector` casts.
7347        .and_then(|atom| self.finish_postfix_casts(atom))
7348    }
7349
7350    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
7351    /// Both bind tighter than any binary op.
7352    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
7353        loop {
7354            if matches!(self.peek(), Token::DoubleColon) {
7355                self.advance();
7356                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
7357                // target set to include INTERVAL (reserved Token),
7358                // TIMESTAMPTZ, and PG catalog regtype / regclass.
7359                // mailrs follow-up H3a + H3b.
7360                let target = match self.advance() {
7361                    Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
7362                        "int" | "integer" | "int4" => {
7363                            if matches!(self.peek(), Token::LBracket)
7364                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7365                            {
7366                                self.advance();
7367                                self.advance();
7368                                CastTarget::IntArray
7369                            } else {
7370                                CastTarget::Int
7371                            }
7372                        }
7373                        "bigint" | "int8" => {
7374                            if matches!(self.peek(), Token::LBracket)
7375                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7376                            {
7377                                self.advance();
7378                                self.advance();
7379                                CastTarget::BigIntArray
7380                            } else {
7381                                CastTarget::BigInt
7382                            }
7383                        }
7384                        "float" | "double" | "real" => CastTarget::Float,
7385                        "text" => {
7386                            // v7.10.11 — `::TEXT[]` widens to TextArray.
7387                            if matches!(self.peek(), Token::LBracket)
7388                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7389                            {
7390                                self.advance();
7391                                self.advance();
7392                                CastTarget::TextArray
7393                            } else {
7394                                CastTarget::Text
7395                            }
7396                        }
7397                        "bool" | "boolean" => CastTarget::Bool,
7398                        "vector" => CastTarget::Vector,
7399                        "date" => CastTarget::Date,
7400                        "timestamp" | "datetime" => CastTarget::Timestamp,
7401                        "timestamptz" => CastTarget::Timestamptz,
7402                        "interval" => CastTarget::Interval,
7403                        "json" => CastTarget::Json,
7404                        "jsonb" => CastTarget::Jsonb,
7405                        "regtype" => CastTarget::RegType,
7406                        "regclass" => CastTarget::RegClass,
7407                        // v7.12.0 — `::tsvector` / `::tsquery`.
7408                        // Engine decodes the LHS text via the PG
7409                        // external form parser.
7410                        "tsvector" => CastTarget::TsVector,
7411                        "tsquery" => CastTarget::TsQuery,
7412                        // v7.17.0 — `::uuid`. Engine decodes the LHS
7413                        // text via `spg_storage::parse_uuid_str`.
7414                        "uuid" => CastTarget::Uuid,
7415                        // v7.18 — `::bytea`. Engine decodes the LHS
7416                        // text via the PG hex form (`'\xdeadbeef'`)
7417                        // or escape form (`'\\x05\\x00'`). Closes
7418                        // mailrs D-pre #3 reverse-acceptance gap.
7419                        "bytea" => CastTarget::Bytea,
7420                        // v7.17.0 Phase 3.P0-47 — `::inet` / `::cidr` /
7421                        // `::macaddr`. SPG stores these as Text (Phase 7);
7422                        // the cast is a no-op passthrough so containment
7423                        // and overlap operators can read the textual form.
7424                        "inet" | "cidr" | "macaddr" => CastTarget::Text,
7425                        other => {
7426                            return Err(ParseError {
7427                                message: format!("unsupported cast target `::{other}`"),
7428                                token_pos: self.pos.saturating_sub(1),
7429                            });
7430                        }
7431                    },
7432                    Token::Interval => CastTarget::Interval,
7433                    other => {
7434                        return Err(ParseError {
7435                            message: format!("expected type ident after `::`, got {other:?}"),
7436                            token_pos: self.pos.saturating_sub(1),
7437                        });
7438                    }
7439                };
7440                expr = Expr::Cast {
7441                    expr: Box::new(expr),
7442                    target,
7443                };
7444                continue;
7445            }
7446            if matches!(self.peek(), Token::Is) {
7447                self.advance();
7448                let negated = if matches!(self.peek(), Token::Not) {
7449                    self.advance();
7450                    true
7451                } else {
7452                    false
7453                };
7454                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
7455                // mailrs pg_dump.
7456                if matches!(self.peek(), Token::Distinct) {
7457                    self.advance();
7458                    if !matches!(self.peek(), Token::From) {
7459                        return Err(self.err(format!(
7460                            "expected FROM after IS{} DISTINCT, got {:?}",
7461                            if negated { " NOT" } else { "" },
7462                            self.peek()
7463                        )));
7464                    }
7465                    self.advance();
7466                    // Right-hand side: parse at the same precedence
7467                    // tier as comparison so `x IS DISTINCT FROM a + b`
7468                    // groups as `x IS DISTINCT FROM (a + b)`.
7469                    let rhs = self.parse_expr(20)?;
7470                    let op = if negated {
7471                        BinOp::IsNotDistinctFrom
7472                    } else {
7473                        BinOp::IsDistinctFrom
7474                    };
7475                    expr = Expr::Binary {
7476                        op,
7477                        lhs: Box::new(expr),
7478                        rhs: Box::new(rhs),
7479                    };
7480                    continue;
7481                }
7482                if !matches!(self.peek(), Token::Null) {
7483                    return Err(self.err(format!(
7484                        "expected NULL or DISTINCT after IS{}, got {:?}",
7485                        if negated { " NOT" } else { "" },
7486                        self.peek()
7487                    )));
7488                }
7489                self.advance();
7490                expr = Expr::IsNull {
7491                    expr: Box::new(expr),
7492                    negated,
7493                };
7494                continue;
7495            }
7496            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
7497            // Look one token ahead so a stray `NOT` not followed by any of
7498            // these flows through to the early return below untouched.
7499            let negated = if matches!(self.peek(), Token::Not) {
7500                let next = self.tokens.get(self.pos + 1);
7501                matches!(next, Some(Token::Between | Token::In | Token::Like))
7502            } else {
7503                false
7504            };
7505            if negated {
7506                self.advance();
7507            }
7508            if matches!(self.peek(), Token::Between) {
7509                expr = self.parse_between_tail(expr, negated)?;
7510                continue;
7511            }
7512            if matches!(self.peek(), Token::In) {
7513                expr = self.parse_in_tail(expr, negated)?;
7514                continue;
7515            }
7516            if matches!(self.peek(), Token::Like) {
7517                self.advance();
7518                // Pattern at the same precedence as other comparison RHSes —
7519                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
7520                let pattern = self.parse_expr(5)?;
7521                expr = Expr::Like {
7522                    expr: Box::new(expr),
7523                    pattern: Box::new(pattern),
7524                    negated,
7525                };
7526                continue;
7527            }
7528            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
7529            // returns NULL for out-of-range. Multiple subscripts
7530            // chain: `a[i][j]` parses left-to-right.
7531            if matches!(self.peek(), Token::LBracket) {
7532                self.advance();
7533                let index = self.parse_expr(0)?;
7534                if !matches!(self.peek(), Token::RBracket) {
7535                    return Err(self.err(alloc::format!(
7536                        "expected ']' after array index, got {:?}",
7537                        self.peek()
7538                    )));
7539                }
7540                self.advance();
7541                expr = Expr::ArraySubscript {
7542                    target: Box::new(expr),
7543                    index: Box::new(index),
7544                };
7545                continue;
7546            }
7547            return Ok(expr);
7548        }
7549    }
7550
7551    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
7552    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
7553    /// `AND` is not swallowed.
7554    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7555        self.advance(); // BETWEEN
7556        let low = self.parse_expr(5)?;
7557        if !matches!(self.peek(), Token::And) {
7558            return Err(self.err(format!(
7559                "expected AND after BETWEEN low bound, got {:?}",
7560                self.peek()
7561            )));
7562        }
7563        self.advance();
7564        let high = self.parse_expr(5)?;
7565        let target = Box::new(expr);
7566        let combined = Expr::Binary {
7567            lhs: Box::new(Expr::Binary {
7568                lhs: target.clone(),
7569                op: BinOp::GtEq,
7570                rhs: Box::new(low),
7571            }),
7572            op: BinOp::And,
7573            rhs: Box::new(Expr::Binary {
7574                lhs: target,
7575                op: BinOp::LtEq,
7576                rhs: Box::new(high),
7577            }),
7578        };
7579        Ok(maybe_not(combined, negated))
7580    }
7581
7582    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
7583    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
7584    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
7585    /// Caller already consumed the leading `WITH` ident.
7586    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
7587        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
7588        // Comes through as an identifier; consume it if present and
7589        // mark every CTE in the clause as recursive (PG semantics —
7590        // the flag is per-WITH, not per-CTE).
7591        let mut recursive = false;
7592        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
7593            && s.eq_ignore_ascii_case("recursive")
7594        {
7595            self.advance();
7596            recursive = true;
7597        }
7598        let mut ctes = Vec::new();
7599        loop {
7600            let name = self.expect_ident_like()?;
7601            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
7602            // PG uses these to rename the body's output columns; we
7603            // do the same below by overriding `columns[i].name`.
7604            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
7605                self.advance();
7606                let mut names = Vec::new();
7607                loop {
7608                    names.push(self.expect_ident_like()?);
7609                    if matches!(self.peek(), Token::Comma) {
7610                        self.advance();
7611                        continue;
7612                    }
7613                    break;
7614                }
7615                if !matches!(self.peek(), Token::RParen) {
7616                    return Err(self.err(format!(
7617                        "expected ')' to close CTE column list, got {:?}",
7618                        self.peek()
7619                    )));
7620                }
7621                self.advance();
7622                names
7623            } else {
7624                Vec::new()
7625            };
7626            // AS is a reserved Token::As (used by SELECT-item / FROM
7627            // aliasing) — handle it specially rather than as a bare
7628            // ident.
7629            if !matches!(self.peek(), Token::As) {
7630                return Err(self.err(format!(
7631                    "expected AS after CTE name {name:?}, got {:?}",
7632                    self.peek()
7633                )));
7634            }
7635            self.advance();
7636            if !matches!(self.peek(), Token::LParen) {
7637                return Err(self.err(format!(
7638                    "expected '(' after AS in WITH clause, got {:?}",
7639                    self.peek()
7640                )));
7641            }
7642            self.advance();
7643            if !matches!(self.peek(), Token::Select) {
7644                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
7645            }
7646            let inner = self.parse_select_stmt()?;
7647            if !matches!(self.peek(), Token::RParen) {
7648                return Err(self.err(format!(
7649                    "expected ')' after CTE body, got {:?}",
7650                    self.peek()
7651                )));
7652            }
7653            self.advance();
7654            let Statement::Select(body) = inner else {
7655                unreachable!("parse_select_stmt returns Select")
7656            };
7657            ctes.push(crate::ast::Cte {
7658                name,
7659                body,
7660                recursive,
7661                column_overrides,
7662            });
7663            if matches!(self.peek(), Token::Comma) {
7664                self.advance();
7665                continue;
7666            }
7667            break;
7668        }
7669        // The body SELECT follows. Must start with SELECT.
7670        if !matches!(self.peek(), Token::Select) {
7671            return Err(self.err(format!(
7672                "expected SELECT after WITH clause, got {:?}",
7673                self.peek()
7674            )));
7675        }
7676        let body_stmt = self.parse_select_stmt()?;
7677        let Statement::Select(mut body) = body_stmt else {
7678            unreachable!()
7679        };
7680        body.ctes = ctes;
7681        Ok(Statement::Select(body))
7682    }
7683
7684    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
7685    /// already consumed the leading `EXISTS` ident via
7686    /// `self.advance()`.
7687    /// v7.13.0 — parse the rest of a `CASE … END` expression after
7688    /// the leading `CASE` ident has been consumed (mailrs round-5
7689    /// G9). Supports both the searched form
7690    /// (`CASE WHEN cond THEN val …`) and the simple form
7691    /// (`CASE operand WHEN val THEN val …`).
7692    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
7693        // Disambiguate searched vs simple form: if the next token
7694        // is `WHEN`, we're in the searched form. Otherwise the
7695        // intervening expression is the operand.
7696        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
7697            None
7698        } else {
7699            Some(Box::new(self.parse_expr(0)?))
7700        };
7701        let mut branches: Vec<(Expr, Expr)> = Vec::new();
7702        loop {
7703            match self.peek() {
7704                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
7705                    self.advance();
7706                    let cond = self.parse_expr(0)?;
7707                    match self.peek() {
7708                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
7709                            self.advance();
7710                        }
7711                        other => {
7712                            return Err(self.err(alloc::format!(
7713                                "expected THEN after CASE WHEN <expr>, got {other:?}"
7714                            )));
7715                        }
7716                    }
7717                    let value = self.parse_expr(0)?;
7718                    branches.push((cond, value));
7719                }
7720                _ => break,
7721            }
7722        }
7723        if branches.is_empty() {
7724            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
7725        }
7726        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
7727        {
7728            self.advance();
7729            Some(Box::new(self.parse_expr(0)?))
7730        } else {
7731            None
7732        };
7733        match self.peek() {
7734            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
7735                self.advance();
7736            }
7737            other => {
7738                return Err(self.err(alloc::format!(
7739                    "expected END to close CASE expression, got {other:?}"
7740                )));
7741            }
7742        }
7743        Ok(Expr::Case {
7744            operand,
7745            branches,
7746            else_branch,
7747        })
7748    }
7749
7750    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
7751        if !matches!(self.peek(), Token::LParen) {
7752            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
7753        }
7754        self.advance();
7755        let inner = self.parse_select_stmt()?;
7756        if !matches!(self.peek(), Token::RParen) {
7757            return Err(self.err(format!(
7758                "expected ')' after EXISTS-subquery, got {:?}",
7759                self.peek()
7760            )));
7761        }
7762        self.advance();
7763        let Statement::Select(s) = inner else {
7764            unreachable!("parse_select_stmt returns Select")
7765        };
7766        Ok(Expr::Exists {
7767            subquery: Box::new(s),
7768            negated,
7769        })
7770    }
7771
7772    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7773        self.advance(); // IN
7774        if !matches!(self.peek(), Token::LParen) {
7775            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
7776        }
7777        self.advance();
7778        // v4.10: `IN (SELECT ...)` — subquery branch.
7779        if matches!(self.peek(), Token::Select) {
7780            let inner = self.parse_select_stmt()?;
7781            if !matches!(self.peek(), Token::RParen) {
7782                return Err(self.err(format!(
7783                    "expected ')' after IN-subquery, got {:?}",
7784                    self.peek()
7785                )));
7786            }
7787            self.advance();
7788            let Statement::Select(s) = inner else {
7789                unreachable!("parse_select_stmt always returns Statement::Select")
7790            };
7791            return Ok(Expr::InSubquery {
7792                expr: Box::new(expr),
7793                subquery: Box::new(s),
7794                negated,
7795            });
7796        }
7797        let mut elements = Vec::new();
7798        if !matches!(self.peek(), Token::RParen) {
7799            loop {
7800                elements.push(self.parse_expr(0)?);
7801                match self.peek() {
7802                    Token::Comma => {
7803                        self.advance();
7804                    }
7805                    Token::RParen => break,
7806                    other => {
7807                        return Err(
7808                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
7809                        );
7810                    }
7811                }
7812            }
7813        }
7814        self.advance(); // ')'
7815        let target = Box::new(expr);
7816        let combined = if elements.is_empty() {
7817            Expr::Literal(Literal::Bool(false))
7818        } else {
7819            let mut iter = elements.into_iter();
7820            let first = iter.next().unwrap();
7821            let mut acc = Expr::Binary {
7822                lhs: target.clone(),
7823                op: BinOp::Eq,
7824                rhs: Box::new(first),
7825            };
7826            for elt in iter {
7827                acc = Expr::Binary {
7828                    lhs: Box::new(acc),
7829                    op: BinOp::Or,
7830                    rhs: Box::new(Expr::Binary {
7831                        lhs: target.clone(),
7832                        op: BinOp::Eq,
7833                        rhs: Box::new(elt),
7834                    }),
7835                };
7836            }
7837            acc
7838        };
7839        Ok(maybe_not(combined, negated))
7840    }
7841
7842    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
7843    /// already consumed by the caller. Elements must be numeric literals
7844    /// (with optional unary `-`); any compound expression is rejected at
7845    /// parse time so the runtime never needs to evaluate inside a vector.
7846    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
7847    /// has already consumed the `EXTRACT` token before calling us —
7848    /// we pick up at the opening `(`.
7849    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
7850    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
7851    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
7852    /// per-column OR-fold of
7853    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7854    /// term)` so the existing FTS evaluator handles semantics.
7855    ///
7856    /// The mode modifier is accepted-and-ignored at v7.17 — all
7857    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
7858    /// mode operators (`+foo -bar`) would need their own parser
7859    /// (Phase 2.2c); customers who hit them today already get a
7860    /// correct lexeme-match against the bare term, only without
7861    /// the +/- precedence the customer asked for.
7862    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
7863        // Already at `MATCH`-consumed position; the dispatcher
7864        // confirmed the next token is `(`.
7865        if !matches!(self.peek(), Token::LParen) {
7866            return Err(self.err(alloc::format!(
7867                "expected '(' after MATCH, got {:?}",
7868                self.peek()
7869            )));
7870        }
7871        self.advance();
7872        let mut cols: Vec<Expr> = Vec::new();
7873        loop {
7874            cols.push(self.parse_expr(0)?);
7875            match self.peek() {
7876                Token::Comma => {
7877                    self.advance();
7878                }
7879                Token::RParen => break,
7880                other => {
7881                    return Err(self.err(alloc::format!(
7882                        "expected ',' or ')' in MATCH column list, got {other:?}"
7883                    )));
7884                }
7885            }
7886        }
7887        self.advance(); // ')'
7888        // Expect AGAINST.
7889        match self.peek() {
7890            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
7891                self.advance();
7892            }
7893            other => {
7894                return Err(self.err(alloc::format!(
7895                    "expected AGAINST after MATCH column list, got {other:?}"
7896                )));
7897            }
7898        }
7899        if !matches!(self.peek(), Token::LParen) {
7900            return Err(self.err(alloc::format!(
7901                "expected '(' after AGAINST, got {:?}",
7902                self.peek()
7903            )));
7904        }
7905        self.advance();
7906        // Read AGAINST's argument as a single primary token —
7907        // string literal, placeholder, or column-ref ident. We
7908        // can't call `parse_expr` / `parse_unary` here because
7909        // the postfix chain inside `parse_atom` would greedily
7910        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
7911        // and fail at "expected '(' after IN". Customers always
7912        // write a literal or bound parameter in AGAINST, so this
7913        // restriction is non-blocking; the error path explains
7914        // the limit if a more complex expression shows up.
7915        let term = match self.advance() {
7916            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
7917            Token::Placeholder(n) => Expr::Placeholder(n),
7918            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
7919                qualifier: None,
7920                name: s,
7921            }),
7922            other => {
7923                return Err(self.err(alloc::format!(
7924                    "MATCH ... AGAINST(<term>) expects a string literal, \
7925                     bound parameter, or column ref, got {other:?}"
7926                )));
7927            }
7928        };
7929        // Optional mode tail — accept-and-ignore at v7.17:
7930        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
7931        //   IN BOOLEAN MODE
7932        //   WITH QUERY EXPANSION
7933        loop {
7934            match self.peek() {
7935                // IN lexes as a reserved Token::In, not an ident,
7936                // so it gets its own arm.
7937                Token::In => {
7938                    self.advance();
7939                }
7940                Token::Ident(s) | Token::QuotedIdent(s)
7941                    if s.eq_ignore_ascii_case("natural")
7942                        || s.eq_ignore_ascii_case("language")
7943                        || s.eq_ignore_ascii_case("boolean")
7944                        || s.eq_ignore_ascii_case("mode")
7945                        || s.eq_ignore_ascii_case("with")
7946                        || s.eq_ignore_ascii_case("query")
7947                        || s.eq_ignore_ascii_case("expansion") =>
7948                {
7949                    self.advance();
7950                }
7951                _ => break,
7952            }
7953        }
7954        if !matches!(self.peek(), Token::RParen) {
7955            return Err(self.err(alloc::format!(
7956                "expected ')' to close AGAINST, got {:?}",
7957                self.peek()
7958            )));
7959        }
7960        self.advance();
7961        // Build per-column `to_tsvector('simple', col) @@
7962        // plainto_tsquery('simple', term)` and OR-fold.
7963        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
7964        let plainto = Expr::FunctionCall {
7965            name: String::from("plainto_tsquery"),
7966            args: alloc::vec![simple_lit(), term.clone()],
7967        };
7968        let mut folded: Option<Expr> = None;
7969        for col in cols {
7970            let to_tsv = Expr::FunctionCall {
7971                name: String::from("to_tsvector"),
7972                args: alloc::vec![simple_lit(), col],
7973            };
7974            let leaf = Expr::Binary {
7975                lhs: Box::new(to_tsv),
7976                op: crate::ast::BinOp::TsMatch,
7977                rhs: Box::new(plainto.clone()),
7978            };
7979            folded = Some(match folded {
7980                None => leaf,
7981                Some(prev) => Expr::Binary {
7982                    lhs: Box::new(prev),
7983                    op: crate::ast::BinOp::Or,
7984                    rhs: Box::new(leaf),
7985                },
7986            });
7987        }
7988        match folded {
7989            Some(e) => Ok(e),
7990            None => Err(self.err(String::from(
7991                "MATCH(...) AGAINST(...) requires at least one column",
7992            ))),
7993        }
7994    }
7995
7996    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
7997        if !matches!(self.peek(), Token::LParen) {
7998            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
7999        }
8000        self.advance();
8001        let field_name = self.expect_ident_like()?;
8002        let field = match field_name.to_ascii_lowercase().as_str() {
8003            "year" => ExtractField::Year,
8004            "month" => ExtractField::Month,
8005            "day" => ExtractField::Day,
8006            "hour" => ExtractField::Hour,
8007            "minute" => ExtractField::Minute,
8008            "second" => ExtractField::Second,
8009            "microsecond" | "microseconds" => ExtractField::Microsecond,
8010            other => {
8011                return Err(self.err(format!(
8012                    "unknown EXTRACT field {other:?}; \
8013                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND"
8014                )));
8015            }
8016        };
8017        if !matches!(self.peek(), Token::From) {
8018            return Err(self.err(format!(
8019                "expected FROM after EXTRACT field, got {:?}",
8020                self.peek()
8021            )));
8022        }
8023        self.advance();
8024        let source = self.parse_expr(0)?;
8025        if !matches!(self.peek(), Token::RParen) {
8026            return Err(self.err(format!(
8027                "expected ')' to close EXTRACT, got {:?}",
8028                self.peek()
8029            )));
8030        }
8031        self.advance();
8032        Ok(Expr::Extract {
8033            field,
8034            source: Box::new(source),
8035        })
8036    }
8037
8038    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
8039    /// is already consumed; we expect a single string literal next and
8040    /// resolve it into `Literal::Interval` at parse time so the engine
8041    /// never has to re-tokenise inside the string.
8042    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
8043        let tok = self.advance();
8044        let Token::String(text) = tok else {
8045            return Err(self.err(format!(
8046                "expected string literal after INTERVAL, got {tok:?}"
8047            )));
8048        };
8049        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
8050            message: format!(
8051                "cannot parse INTERVAL {text:?}; \
8052                     expected `<n> <unit> [<n> <unit> ...]` with units \
8053                     microsecond[s], millisecond[s], second[s], minute[s], \
8054                     hour[s], day[s], week[s], month[s], year[s]"
8055            ),
8056            token_pos: self.pos.saturating_sub(1),
8057        })?;
8058        Ok(Expr::Literal(Literal::Interval {
8059            months,
8060            micros,
8061            text,
8062        }))
8063    }
8064
8065    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
8066        let mut elems = Vec::new();
8067        if matches!(self.peek(), Token::RBracket) {
8068            self.advance();
8069            return Ok(Expr::Literal(Literal::Vector(elems)));
8070        }
8071        loop {
8072            let e = self.parse_expr(0)?;
8073            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
8074                message: format!("vector element must be a numeric literal, got {e:?}"),
8075                token_pos: self.pos,
8076            })?;
8077            elems.push(x);
8078            match self.peek() {
8079                Token::Comma => {
8080                    self.advance();
8081                }
8082                Token::RBracket => {
8083                    self.advance();
8084                    break;
8085                }
8086                other => {
8087                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
8088                }
8089            }
8090        }
8091        Ok(Expr::Literal(Literal::Vector(elems)))
8092    }
8093
8094    /// Atom that started with an identifier: could be `t.col`, `col`, or
8095    /// `func(arg, ...)`. Detect each shape by looking at the next token.
8096    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
8097    /// [, ...])`. Caller has already consumed `OVER`. Either clause
8098    /// is optional; an empty `()` is also legal (PG semantics).
8099    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
8100    /// modifier between `name(args)` and `OVER (...)`. Default is
8101    /// `Respect`. Unrecognised idents leave the stream unchanged.
8102    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
8103        let Token::Ident(s) = self.peek().clone() else {
8104            return NullTreatment::Respect;
8105        };
8106        let is_ignore = s.eq_ignore_ascii_case("ignore");
8107        let is_respect = s.eq_ignore_ascii_case("respect");
8108        if !is_ignore && !is_respect {
8109            return NullTreatment::Respect;
8110        }
8111        // Lookahead for NULLS — only consume both tokens together.
8112        // pos+1 must hold a "nulls" ident.
8113        if self.pos + 1 < self.tokens.len()
8114            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
8115            && s2.eq_ignore_ascii_case("nulls")
8116        {
8117            self.advance();
8118            self.advance();
8119            return if is_ignore {
8120                NullTreatment::Ignore
8121            } else {
8122                NullTreatment::Respect
8123            };
8124        }
8125        NullTreatment::Respect
8126    }
8127
8128    /// No frame clause is supported.
8129    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
8130    fn parse_over_clause(
8131        &mut self,
8132    ) -> Result<(Vec<Expr>, Vec<(Expr, bool)>, Option<WindowFrame>), ParseError> {
8133        if !matches!(self.peek(), Token::LParen) {
8134            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
8135        }
8136        self.advance();
8137        let mut partition_by = Vec::new();
8138        let mut order_by = Vec::new();
8139        // PARTITION BY ?
8140        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8141            && s.eq_ignore_ascii_case("partition")
8142        {
8143            self.advance();
8144            if !matches!(self.peek(), Token::By) {
8145                return Err(self.err(format!(
8146                    "expected BY after PARTITION, got {:?}",
8147                    self.peek()
8148                )));
8149            }
8150            self.advance();
8151            loop {
8152                partition_by.push(self.parse_expr(0)?);
8153                if matches!(self.peek(), Token::Comma) {
8154                    self.advance();
8155                    continue;
8156                }
8157                break;
8158            }
8159        }
8160        // ORDER BY ?
8161        if matches!(self.peek(), Token::Order) {
8162            self.advance();
8163            if !matches!(self.peek(), Token::By) {
8164                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
8165            }
8166            self.advance();
8167            loop {
8168                let e = self.parse_expr(0)?;
8169                let desc = if matches!(self.peek(), Token::Desc) {
8170                    self.advance();
8171                    true
8172                } else if matches!(self.peek(), Token::Asc) {
8173                    self.advance();
8174                    false
8175                } else {
8176                    false
8177                };
8178                order_by.push((e, desc));
8179                if matches!(self.peek(), Token::Comma) {
8180                    self.advance();
8181                    continue;
8182                }
8183                break;
8184            }
8185        }
8186        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
8187        // Both keywords come through the lexer as identifiers; match
8188        // case-insensitively.
8189        let mut frame: Option<WindowFrame> = None;
8190        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8191            let kind = if s.eq_ignore_ascii_case("rows") {
8192                Some(FrameKind::Rows)
8193            } else if s.eq_ignore_ascii_case("range") {
8194                Some(FrameKind::Range)
8195            } else {
8196                None
8197            };
8198            if let Some(kind) = kind {
8199                self.advance();
8200                frame = Some(self.parse_frame_tail(kind)?);
8201            }
8202        }
8203        if !matches!(self.peek(), Token::RParen) {
8204            return Err(self.err(format!(
8205                "expected ')' to close OVER clause, got {:?}",
8206                self.peek()
8207            )));
8208        }
8209        self.advance();
8210        Ok((partition_by, order_by, frame))
8211    }
8212
8213    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
8214    /// or `RANGE` keyword was just consumed. Accepts both
8215    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
8216    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
8217    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
8218    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
8219        if matches!(self.peek(), Token::Between) {
8220            self.advance();
8221            let start = self.parse_frame_bound()?;
8222            if !matches!(self.peek(), Token::And) {
8223                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
8224            }
8225            self.advance();
8226            let end = self.parse_frame_bound()?;
8227            Ok(WindowFrame {
8228                kind,
8229                start,
8230                end: Some(end),
8231            })
8232        } else {
8233            let start = self.parse_frame_bound()?;
8234            Ok(WindowFrame {
8235                kind,
8236                start,
8237                end: None,
8238            })
8239        }
8240    }
8241
8242    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
8243    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
8244    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
8245        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
8246        if let Token::Integer(n) = *self.peek() {
8247            self.advance();
8248            let n: u64 = u64::try_from(n).map_err(|_| {
8249                self.err(format!(
8250                    "invalid frame offset {n} — expected non-negative integer"
8251                ))
8252            })?;
8253            let dir = self.expect_ident_like()?;
8254            return if dir.eq_ignore_ascii_case("preceding") {
8255                Ok(FrameBound::OffsetPreceding(n))
8256            } else if dir.eq_ignore_ascii_case("following") {
8257                Ok(FrameBound::OffsetFollowing(n))
8258            } else {
8259                Err(self.err(format!(
8260                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
8261                )))
8262            };
8263        }
8264        let first = self.expect_ident_like()?;
8265        if first.eq_ignore_ascii_case("unbounded") {
8266            let dir = self.expect_ident_like()?;
8267            return if dir.eq_ignore_ascii_case("preceding") {
8268                Ok(FrameBound::UnboundedPreceding)
8269            } else if dir.eq_ignore_ascii_case("following") {
8270                Ok(FrameBound::UnboundedFollowing)
8271            } else {
8272                Err(self.err(format!(
8273                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
8274                )))
8275            };
8276        }
8277        if first.eq_ignore_ascii_case("current") {
8278            let row = self.expect_ident_like()?;
8279            if !row.eq_ignore_ascii_case("row") {
8280                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
8281            }
8282            return Ok(FrameBound::CurrentRow);
8283        }
8284        Err(self.err(format!(
8285            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
8286        )))
8287    }
8288
8289    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
8290        if matches!(self.peek(), Token::Dot) {
8291            self.advance();
8292            let name = self.expect_ident_like()?;
8293            // v7.14.0 — schema-qualified function call
8294            // `<schema>.<fn>(args)`. PG dumps emit
8295            // `pg_catalog.set_config(...)` in the preamble. SPG
8296            // is single-namespace: drop the schema prefix and
8297            // route the dispatch on the bare function name.
8298            if matches!(self.peek(), Token::LParen) {
8299                return self.finish_ident_atom(name);
8300            }
8301            return Ok(Expr::Column(ColumnName {
8302                qualifier: Some(first),
8303                name,
8304            }));
8305        }
8306        if matches!(self.peek(), Token::LParen) {
8307            self.advance();
8308            // `COUNT(*)` — special-cased here because `*` isn't a normal
8309            // expression token. Lower-case match on `first` since the lexer
8310            // folds identifiers.
8311            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
8312                self.advance();
8313                if !matches!(self.peek(), Token::RParen) {
8314                    return Err(self.err(format!(
8315                        "expected ')' after COUNT(*), got {:?}",
8316                        self.peek()
8317                    )));
8318                }
8319                self.advance();
8320                // v4.12: COUNT(*) OVER (...) — same window tail.
8321                let null_treatment = self.parse_null_treatment_modifier();
8322                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8323                    && s.eq_ignore_ascii_case("over")
8324                {
8325                    self.advance();
8326                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
8327                    return Ok(Expr::WindowFunction {
8328                        name: "count_star".into(),
8329                        args: Vec::new(),
8330                        partition_by,
8331                        order_by,
8332                        frame,
8333                        null_treatment,
8334                    });
8335                }
8336                return Ok(Expr::FunctionCall {
8337                    name: "count_star".into(),
8338                    args: Vec::new(),
8339                });
8340            }
8341            // Function call. PG-style: zero-or-more comma-separated args.
8342            let mut args = Vec::new();
8343            if !matches!(self.peek(), Token::RParen) {
8344                loop {
8345                    args.push(self.parse_expr(0)?);
8346                    match self.peek() {
8347                        Token::Comma => {
8348                            self.advance();
8349                        }
8350                        Token::RParen => break,
8351                        other => {
8352                            return Err(self.err(format!(
8353                                "expected ',' or ')' in function args, got {other:?}"
8354                            )));
8355                        }
8356                    }
8357                }
8358            }
8359            self.advance(); // consume ')'
8360            // v4.12: window-function tail — `name(args) OVER (...)`.
8361            // Promotes the just-parsed FunctionCall into a
8362            // WindowFunction node carrying partition + order.
8363            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
8364            // / `RESPECT NULLS OVER (...)` between the closing paren
8365            // and `OVER`.
8366            let null_treatment = self.parse_null_treatment_modifier();
8367            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8368                && s.eq_ignore_ascii_case("over")
8369            {
8370                self.advance();
8371                let (partition_by, order_by, frame) = self.parse_over_clause()?;
8372                return Ok(Expr::WindowFunction {
8373                    name: first,
8374                    args,
8375                    partition_by,
8376                    order_by,
8377                    frame,
8378                    null_treatment,
8379                });
8380            }
8381            return Ok(Expr::FunctionCall { name: first, args });
8382        }
8383        // v7.9.20 — SQL-standard parenless keyword expressions
8384        // (PG treats these as functions called without parens).
8385        // Resolve to a synthetic FunctionCall so the engine's
8386        // eval path reuses the existing function-call routing.
8387        // mailrs G3.
8388        let lc = first.to_ascii_lowercase();
8389        if matches!(
8390            lc.as_str(),
8391            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
8392        ) {
8393            return Ok(Expr::FunctionCall {
8394                name: lc,
8395                args: Vec::new(),
8396            });
8397        }
8398        Ok(Expr::Column(ColumnName {
8399            qualifier: None,
8400            name: first,
8401        }))
8402    }
8403}
8404
8405/// v6.8.2 — walk an expression tree and return the first column
8406/// reference's bare name. Used by `parse_create_index_stmt_after_create`
8407/// to derive `CreateIndexStatement.column` from an expression
8408/// key (so downstream planner code resolving a primary column
8409/// position keeps working with expression indexes). Returns
8410/// `None` when the expression has no column ref at all — caller
8411/// surfaces that as a parse error.
8412fn extract_first_column(expr: &Expr) -> Option<String> {
8413    match expr {
8414        Expr::Column(cn) => Some(cn.name.clone()),
8415        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
8416        Expr::Binary { lhs, rhs, .. } => {
8417            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
8418        }
8419        Expr::Unary { expr: e, .. } => extract_first_column(e),
8420        _ => None,
8421    }
8422}
8423
8424fn maybe_not(expr: Expr, negated: bool) -> Expr {
8425    if negated {
8426        Expr::Unary {
8427            op: UnOp::Not,
8428            expr: Box::new(expr),
8429        }
8430    } else {
8431        expr
8432    }
8433}
8434
8435fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
8436    let pair = match tok {
8437        Token::Or => (BinOp::Or, 1),
8438        Token::And => (BinOp::And, 2),
8439        Token::Eq => (BinOp::Eq, 4),
8440        Token::NotEq => (BinOp::NotEq, 4),
8441        Token::Lt => (BinOp::Lt, 4),
8442        Token::LtEq => (BinOp::LtEq, 4),
8443        Token::Gt => (BinOp::Gt, 4),
8444        Token::GtEq => (BinOp::GtEq, 4),
8445        // pgvector distance ops all sit on the same rung — tighter than
8446        // comparisons (4) so `col <-> v < threshold` parses correctly.
8447        Token::L2Distance => (BinOp::L2Distance, 5),
8448        Token::InnerProduct => (BinOp::InnerProduct, 5),
8449        Token::CosineDistance => (BinOp::CosineDistance, 5),
8450        Token::Plus => (BinOp::Add, 6),
8451        Token::Minus => (BinOp::Sub, 6),
8452        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
8453        // by the same level as binary additive arithmetic).
8454        Token::Concat => (BinOp::Concat, 6),
8455        Token::Star => (BinOp::Mul, 7),
8456        Token::Slash => (BinOp::Div, 7),
8457        // v4.14: JSON path ops bind tighter than comparisons (4)
8458        // and additive (6) so `doc->'k' = 'v'` parses correctly.
8459        // Same rung as the multiplicative ops.
8460        Token::JsonGet => (BinOp::JsonGet, 7),
8461        Token::JsonGetText => (BinOp::JsonGetText, 7),
8462        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
8463        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
8464        Token::JsonContains => (BinOp::JsonContains, 7),
8465        // v7.12.2 — `@@` binds at the comparison rung (looser than
8466        // arithmetic, tighter than AND / OR). PG places `@@` at
8467        // the same precedence as `=` / `<`, so we follow.
8468        Token::TsMatch => (BinOp::TsMatch, 4),
8469        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
8470        // PG places these at the comparison rung (same level as `=`),
8471        // so we follow.
8472        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
8473        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
8474        Token::InetContains => (BinOp::InetContains, 4),
8475        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
8476        Token::InetOverlap => (BinOp::InetOverlap, 4),
8477        _ => return None,
8478    };
8479    Some(pair)
8480}
8481
8482#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
8483// `as f32` here is intentional: vector elements widen / narrow into f32 on
8484// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
8485// past ~15 decimal digits — both are acceptable for a fixed-precision
8486// pgvector column.
8487/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
8488/// implicit table alias and break trailing clauses. WITH lands
8489/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
8490/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
8491/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
8492/// / VALUES / FOR / LATERAL — all of which would otherwise be
8493/// silently swallowed by `parse_optional_alias`.
8494fn is_alias_stopword(s: &str) -> bool {
8495    matches!(
8496        s.to_ascii_lowercase().as_str(),
8497        "with"
8498            | "on"
8499            | "where"
8500            | "having"
8501            | "group"
8502            | "order"
8503            | "limit"
8504            | "offset"
8505            | "union"
8506            | "except"
8507            | "intersect"
8508            | "returning"
8509            | "set"
8510            | "values"
8511            | "for"
8512            | "lateral"
8513            | "left"
8514            | "right"
8515            | "inner"
8516            | "outer"
8517            | "full"
8518            | "cross"
8519            | "join"
8520            | "natural"
8521            | "using"
8522            | "fetch"
8523    )
8524}
8525
8526fn extract_numeric_literal(e: &Expr) -> Option<f32> {
8527    match e {
8528        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
8529        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
8530        Expr::Unary {
8531            op: UnOp::Neg,
8532            expr,
8533        } => extract_numeric_literal(expr).map(|x| -x),
8534        _ => None,
8535    }
8536}
8537
8538/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
8539/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
8540/// negative. Returns `None` if any pair fails to parse or no pair is found.
8541///
8542/// Recognised units (case-insensitive, optional trailing `s`):
8543/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
8544/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
8545pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
8546    let parts: Vec<&str> = s.split_whitespace().collect();
8547    if parts.is_empty() || !parts.len().is_multiple_of(2) {
8548        return None;
8549    }
8550    let mut months: i32 = 0;
8551    let mut micros: i64 = 0;
8552    let mut i = 0;
8553    while i < parts.len() {
8554        let n: i64 = parts[i].parse().ok()?;
8555        let unit = parts[i + 1].to_ascii_lowercase();
8556        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
8557        match unit_stripped {
8558            "microsecond" => micros = micros.checked_add(n)?,
8559            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
8560            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
8561            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
8562            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
8563            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
8564            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
8565            "month" => {
8566                let n32 = i32::try_from(n).ok()?;
8567                months = months.checked_add(n32)?;
8568            }
8569            "year" => {
8570                let n32 = i32::try_from(n).ok()?;
8571                months = months.checked_add(n32.checked_mul(12)?)?;
8572            }
8573            _ => return None,
8574        }
8575        i += 2;
8576    }
8577    Some((months, micros))
8578}
8579
8580/// v7.12.4 — map a bare type-name identifier (the form that
8581/// appears in a function arg list or RETURNS clause) to a
8582/// [`ColumnTypeName`]. Returns `None` for unknown / extension
8583/// types so the caller can preserve them as
8584/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
8585///
8586/// Subset of the full column-type grammar — we deliberately
8587/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
8588/// here because function-arg types in v7.12.4 are mostly the
8589/// bare form (`text`, `int`, `bytea`, …).
8590fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
8591    Some(match ident.to_ascii_lowercase().as_str() {
8592        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
8593        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
8594        "bigint" => ColumnTypeName::BigInt,
8595        "float" | "double" | "real" => ColumnTypeName::Float,
8596        "text" => ColumnTypeName::Text,
8597        "bool" | "boolean" => ColumnTypeName::Bool,
8598        "date" => ColumnTypeName::Date,
8599        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
8600        "timestamptz" => ColumnTypeName::Timestamptz,
8601        "json" => ColumnTypeName::Json,
8602        "jsonb" => ColumnTypeName::Jsonb,
8603        "bytea" | "bytes" => ColumnTypeName::Bytes,
8604        "tsvector" => ColumnTypeName::TsVector,
8605        "tsquery" => ColumnTypeName::TsQuery,
8606        "uuid" => ColumnTypeName::Uuid,
8607        "time" => ColumnTypeName::Time,
8608        "year" => ColumnTypeName::Year,
8609        "timetz" => ColumnTypeName::TimeTz,
8610        "money" => ColumnTypeName::Money,
8611        _ => return None,
8612    })
8613}
8614
8615/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
8616/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
8617///
8618/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
8619/// / embedded SQL land in v7.12.5+):
8620///
8621/// ```text
8622///   body          := [ws] block [ws]
8623///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
8624///   stmt          := assign | return
8625///   assign        := assign_target := expr
8626///   assign_target := ( NEW | OLD ) . ident | ident
8627///   return        := RETURN ( NEW | OLD | NULL | expr )
8628/// ```
8629///
8630/// `expr` is parsed by recursing into the regular `Parser` — so a
8631/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
8632/// NEW.subject || ' ' || NEW.sender)` body shape works without
8633/// the body parser knowing what `to_tsvector` is.
8634///
8635/// Errors here cause the caller to fall back to
8636/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
8637/// successful, but the executor will refuse to invoke the
8638/// function with an "unparseable body" error.
8639/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
8640/// from the crate root as `spg_sql::parse_function_body`.
8641pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
8642    parse_plpgsql_body(body)
8643}
8644
8645fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
8646    // Use the regular lexer on the body text. The trailing
8647    // `END;` may or may not have a semicolon; the lexer treats
8648    // both forms identically.
8649    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
8650        message: alloc::format!("plpgsql body lex error: {e}"),
8651        token_pos: 0,
8652    })?;
8653    let mut parser = Parser::new(tokens);
8654    parser.parse_plpgsql_block()
8655}
8656
8657#[cfg(test)]
8658mod tests {
8659    use super::*;
8660    use alloc::string::ToString;
8661
8662    fn parse(s: &str) -> Statement {
8663        parse_statement(s).expect("parse ok")
8664    }
8665
8666    fn lit_int(n: i64) -> Expr {
8667        Expr::Literal(Literal::Integer(n))
8668    }
8669
8670    fn col(name: &str) -> Expr {
8671        Expr::Column(ColumnName {
8672            qualifier: None,
8673            name: name.into(),
8674        })
8675    }
8676
8677    #[test]
8678    fn select_single_integer() {
8679        let s = parse("SELECT 1");
8680        let Statement::Select(s) = s else {
8681            panic!("expected SELECT")
8682        };
8683        assert_eq!(s.items.len(), 1);
8684        assert!(s.from.is_none());
8685        assert!(s.where_.is_none());
8686    }
8687
8688    #[test]
8689    fn select_multiple_literal_kinds() {
8690        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
8691        let Statement::Select(s) = s else {
8692            panic!("expected SELECT")
8693        };
8694        assert_eq!(s.items.len(), 5);
8695    }
8696
8697    #[test]
8698    fn select_wildcard_from_table() {
8699        let s = parse("SELECT * FROM users");
8700        let Statement::Select(s) = s else {
8701            panic!("expected SELECT")
8702        };
8703        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
8704        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
8705    }
8706
8707    #[test]
8708    fn select_with_table_alias() {
8709        let s = parse("SELECT * FROM users AS u");
8710        let Statement::Select(s) = s else {
8711            panic!("expected SELECT")
8712        };
8713        let t = &s.from.as_ref().unwrap().primary;
8714        assert_eq!(t.name, "users");
8715        assert_eq!(t.alias.as_deref(), Some("u"));
8716    }
8717
8718    #[test]
8719    fn select_with_where_eq() {
8720        let s = parse("SELECT a FROM t WHERE a = 1");
8721        let Statement::Select(s) = s else {
8722            panic!("expected SELECT")
8723        };
8724        let w = s.where_.unwrap();
8725        assert_eq!(
8726            w,
8727            Expr::Binary {
8728                lhs: Box::new(col("a")),
8729                op: BinOp::Eq,
8730                rhs: Box::new(lit_int(1)),
8731            }
8732        );
8733    }
8734
8735    #[test]
8736    fn arithmetic_precedence() {
8737        let s = parse("SELECT 1 + 2 * 3");
8738        let Statement::Select(s) = s else {
8739            panic!("expected SELECT")
8740        };
8741        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8742            panic!("wildcard?")
8743        };
8744        assert_eq!(
8745            expr,
8746            &Expr::Binary {
8747                lhs: Box::new(lit_int(1)),
8748                op: BinOp::Add,
8749                rhs: Box::new(Expr::Binary {
8750                    lhs: Box::new(lit_int(2)),
8751                    op: BinOp::Mul,
8752                    rhs: Box::new(lit_int(3)),
8753                }),
8754            }
8755        );
8756    }
8757
8758    #[test]
8759    fn parentheses_override_precedence() {
8760        let s = parse("SELECT (1 + 2) * 3");
8761        let Statement::Select(s) = s else {
8762            panic!("expected SELECT")
8763        };
8764        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8765            panic!()
8766        };
8767        assert_eq!(
8768            expr,
8769            &Expr::Binary {
8770                lhs: Box::new(Expr::Binary {
8771                    lhs: Box::new(lit_int(1)),
8772                    op: BinOp::Add,
8773                    rhs: Box::new(lit_int(2)),
8774                }),
8775                op: BinOp::Mul,
8776                rhs: Box::new(lit_int(3)),
8777            }
8778        );
8779    }
8780
8781    #[test]
8782    fn not_binds_below_comparison() {
8783        // `NOT a = 1` should parse as `NOT (a = 1)`.
8784        let s = parse("SELECT NOT a = 1 FROM t");
8785        let Statement::Select(s) = s else {
8786            panic!("expected SELECT")
8787        };
8788        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8789            panic!()
8790        };
8791        assert_eq!(
8792            expr,
8793            &Expr::Unary {
8794                op: UnOp::Not,
8795                expr: Box::new(Expr::Binary {
8796                    lhs: Box::new(col("a")),
8797                    op: BinOp::Eq,
8798                    rhs: Box::new(lit_int(1)),
8799                }),
8800            }
8801        );
8802    }
8803
8804    #[test]
8805    fn unary_minus_binds_above_multiplication() {
8806        // `-a * 2` should be `(-a) * 2`.
8807        let s = parse("SELECT -a * 2 FROM t");
8808        let Statement::Select(s) = s else {
8809            panic!("expected SELECT")
8810        };
8811        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8812            panic!()
8813        };
8814        assert_eq!(
8815            expr,
8816            &Expr::Binary {
8817                lhs: Box::new(Expr::Unary {
8818                    op: UnOp::Neg,
8819                    expr: Box::new(col("a")),
8820                }),
8821                op: BinOp::Mul,
8822                rhs: Box::new(lit_int(2)),
8823            }
8824        );
8825    }
8826
8827    #[test]
8828    fn qualified_column() {
8829        let s = parse("SELECT t.col FROM t");
8830        let Statement::Select(s) = s else {
8831            panic!("expected SELECT")
8832        };
8833        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8834            panic!()
8835        };
8836        assert_eq!(
8837            expr,
8838            &Expr::Column(ColumnName {
8839                qualifier: Some("t".into()),
8840                name: "col".into()
8841            })
8842        );
8843    }
8844
8845    #[test]
8846    fn select_item_alias_with_as() {
8847        let s = parse("SELECT a AS y FROM t");
8848        let Statement::Select(s) = s else {
8849            panic!("expected SELECT")
8850        };
8851        let SelectItem::Expr { alias, .. } = &s.items[0] else {
8852            panic!()
8853        };
8854        assert_eq!(alias.as_deref(), Some("y"));
8855    }
8856
8857    #[test]
8858    fn trailing_semicolon_accepted() {
8859        let s = parse("SELECT 1;");
8860        let Statement::Select(s) = s else {
8861            panic!("expected SELECT")
8862        };
8863        assert_eq!(s.items.len(), 1);
8864    }
8865
8866    #[test]
8867    fn boolean_chain_with_and_or_not() {
8868        // (NOT a) OR (b AND (NOT c))
8869        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
8870        let Statement::Select(s) = s else {
8871            panic!("expected SELECT")
8872        };
8873        let SelectItem::Expr { expr, .. } = &s.items[0] else {
8874            panic!()
8875        };
8876        let expected = Expr::Binary {
8877            lhs: Box::new(Expr::Unary {
8878                op: UnOp::Not,
8879                expr: Box::new(col("a")),
8880            }),
8881            op: BinOp::Or,
8882            rhs: Box::new(Expr::Binary {
8883                lhs: Box::new(col("b")),
8884                op: BinOp::And,
8885                rhs: Box::new(Expr::Unary {
8886                    op: UnOp::Not,
8887                    expr: Box::new(col("c")),
8888                }),
8889            }),
8890        };
8891        assert_eq!(expr, &expected);
8892    }
8893
8894    #[test]
8895    fn empty_input_errors() {
8896        // v7.14.0 — pg_dump preambles emit several comment-only
8897        // / blank-line statements that collapse to Statement::
8898        // Empty rather than a parse error. The old "SELECT in
8899        // message" assertion is stale; verify the new contract:
8900        // empty / whitespace / comment-only input parses to
8901        // Statement::Empty.
8902        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
8903        assert!(matches!(
8904            parse_statement("  \n\t ").unwrap(),
8905            Statement::Empty
8906        ));
8907        // Sanity: malformed-but-non-empty still errors.
8908        assert!(parse_statement("SELECT FROM WHERE").is_err());
8909    }
8910
8911    #[test]
8912    fn unmatched_paren_errors() {
8913        assert!(parse_statement("SELECT (1 + 2").is_err());
8914    }
8915
8916    #[test]
8917    fn display_round_trip_simple_select() {
8918        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
8919        let text = original.to_string();
8920        let again = parse_statement(&text).expect("re-parse");
8921        assert_eq!(original, again);
8922    }
8923
8924    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
8925
8926    #[test]
8927    fn create_table_single_column() {
8928        let s = parse("CREATE TABLE foo (a INT)");
8929        let Statement::CreateTable(c) = s else {
8930            panic!("expected CreateTable")
8931        };
8932        assert_eq!(c.name, "foo");
8933        assert_eq!(c.columns.len(), 1);
8934        assert_eq!(c.columns[0].name, "a");
8935        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
8936        assert!(c.columns[0].nullable);
8937    }
8938
8939    #[test]
8940    fn create_table_multi_column_with_not_null_mix() {
8941        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
8942        let Statement::CreateTable(c) = s else {
8943            panic!()
8944        };
8945        assert_eq!(c.columns.len(), 4);
8946        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
8947        assert!(!c.columns[0].nullable);
8948        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
8949        assert!(c.columns[1].nullable);
8950        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
8951        assert!(!c.columns[2].nullable);
8952        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
8953    }
8954
8955    #[test]
8956    fn create_table_bigint_supported() {
8957        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
8958        let Statement::CreateTable(c) = s else {
8959            panic!()
8960        };
8961        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
8962    }
8963
8964    #[test]
8965    fn create_table_vector_default_is_f32() {
8966        let s = parse("CREATE TABLE t (v VECTOR(128))");
8967        let Statement::CreateTable(c) = s else {
8968            panic!()
8969        };
8970        assert_eq!(
8971            c.columns[0].ty,
8972            ColumnTypeName::Vector {
8973                dim: 128,
8974                encoding: VecEncoding::F32,
8975            },
8976        );
8977    }
8978
8979    #[test]
8980    fn create_table_vector_using_sq8() {
8981        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
8982        // Case-insensitive on both `USING` and the encoding name.
8983        for sql in [
8984            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
8985            "CREATE TABLE t (v VECTOR(128) using sq8)",
8986        ] {
8987            let s = parse(sql);
8988            let Statement::CreateTable(c) = s else {
8989                panic!()
8990            };
8991            assert_eq!(
8992                c.columns[0].ty,
8993                ColumnTypeName::Vector {
8994                    dim: 128,
8995                    encoding: VecEncoding::Sq8,
8996                },
8997                "{sql}",
8998            );
8999        }
9000    }
9001
9002    #[test]
9003    fn create_table_vector_using_unknown_errors() {
9004        // v7.16.1 — the inline `USING <encoding>` shape on
9005        // CREATE TABLE column defs was withdrawn before
9006        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
9007        // (col vector_<metric>_ops)`; the parser now rejects
9008        // USING at column-list position with a clearer
9009        // "expected ',' or ')'" message. Test asserts the
9010        // current rejection, not the old "unknown vector
9011        // encoding" string.
9012        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
9013        assert!(
9014            err.message.contains("USING")
9015                || err.message.contains("using")
9016                || err.message.contains("')'")
9017                || err.message.contains("','"),
9018            "expected USING/column-list rejection, got: {}",
9019            err.message
9020        );
9021    }
9022
9023    #[test]
9024    fn vector_using_sq8_display_roundtrips() {
9025        // The Display impl must produce text that re-parses to the
9026        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
9027        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
9028        let Statement::CreateTable(c) = s else {
9029            panic!()
9030        };
9031        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
9032    }
9033
9034    #[test]
9035    fn parser_recognises_placeholders() {
9036        use crate::ast::{Expr, SelectItem, Statement};
9037        // $N in expression position parses as Expr::Placeholder(N).
9038        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
9039        let Statement::Select(sel) = s else { panic!() };
9040        assert!(matches!(
9041            sel.items[0],
9042            SelectItem::Expr {
9043                expr: Expr::Placeholder(1),
9044                alias: None
9045            }
9046        ));
9047        // $2 + 1
9048        let SelectItem::Expr {
9049            expr: Expr::Binary { lhs, rhs, .. },
9050            ..
9051        } = &sel.items[1]
9052        else {
9053            panic!()
9054        };
9055        assert!(matches!(**lhs, Expr::Placeholder(2)));
9056        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
9057        // WHERE x = $3
9058        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
9059            panic!()
9060        };
9061        assert!(matches!(**rhs, Expr::Placeholder(3)));
9062    }
9063
9064    #[test]
9065    fn parser_rejects_dollar_zero() {
9066        // $0 is not valid in PG; the lexer rejects it.
9067        assert!(parse_statement("SELECT $0").is_err());
9068    }
9069
9070    #[test]
9071    fn placeholder_display_roundtrips() {
9072        // The Display impl must produce text that re-lexes to the
9073        // same Placeholder token.
9074        let s = parse("SELECT $42 FROM t");
9075        let printed = s.to_string();
9076        assert!(printed.contains("$42"));
9077        let again = parse(&printed);
9078        assert_eq!(s, again);
9079    }
9080
9081    #[test]
9082    fn alter_index_rebuild_bare() {
9083        use crate::ast::{AlterIndexTarget, Statement};
9084        let s = parse("ALTER INDEX my_idx REBUILD");
9085        let Statement::AlterIndex(a) = s else {
9086            panic!("expected AlterIndex, got {s:?}")
9087        };
9088        assert_eq!(a.name, "my_idx");
9089        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
9090    }
9091
9092    #[test]
9093    fn alter_index_rebuild_with_encoding() {
9094        use crate::ast::{AlterIndexTarget, Statement};
9095        for (sql, want) in [
9096            (
9097                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
9098                VecEncoding::F32,
9099            ),
9100            (
9101                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
9102                VecEncoding::Sq8,
9103            ),
9104            (
9105                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9106                VecEncoding::F16,
9107            ),
9108        ] {
9109            let s = parse(sql);
9110            let Statement::AlterIndex(a) = s else {
9111                panic!("{sql}: expected AlterIndex")
9112            };
9113            assert_eq!(a.name, "my_idx");
9114            assert_eq!(
9115                a.target,
9116                AlterIndexTarget::Rebuild {
9117                    encoding: Some(want)
9118                },
9119                "{sql}"
9120            );
9121        }
9122    }
9123
9124    #[test]
9125    fn alter_index_rebuild_unknown_encoding_errors() {
9126        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
9127        assert!(
9128            err.message.contains("unknown vector encoding"),
9129            "got: {}",
9130            err.message
9131        );
9132    }
9133
9134    #[test]
9135    fn alter_index_rebuild_display_roundtrips() {
9136        for (input, want) in [
9137            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
9138            (
9139                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9140                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9141            ),
9142            (
9143                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9144                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9145            ),
9146        ] {
9147            let s = parse(input);
9148            assert_eq!(s.to_string(), want);
9149        }
9150    }
9151
9152    #[test]
9153    fn create_table_unknown_type_defers_to_engine() {
9154        // v4.9 picked XML as a parse-time "unsupported column
9155        // type" probe. v7.17.0 Phase 1.4 changed the contract:
9156        // an unknown type ident parses as Text + `user_type_ref`
9157        // so CREATE TABLE can resolve user-defined enum / domain
9158        // types — rejection of truly-unknown types moved to the
9159        // engine's catalog lookup.
9160        let stmt = parse_statement("CREATE TABLE x (a xml)").unwrap();
9161        let Statement::CreateTable(t) = stmt else {
9162            panic!("expected CreateTable");
9163        };
9164        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("xml"));
9165    }
9166
9167    #[test]
9168    fn create_table_missing_table_keyword_errors() {
9169        assert!(parse_statement("CREATE x (a INT)").is_err());
9170    }
9171
9172    #[test]
9173    fn insert_single_value() {
9174        let s = parse("INSERT INTO foo VALUES (42)");
9175        let Statement::Insert(i) = s else {
9176            panic!("expected Insert")
9177        };
9178        assert_eq!(i.table, "foo");
9179        assert_eq!(i.rows.len(), 1);
9180        assert_eq!(i.rows[0].len(), 1);
9181        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
9182    }
9183
9184    #[test]
9185    fn insert_multi_value_with_mixed_literals() {
9186        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
9187        let Statement::Insert(i) = s else { panic!() };
9188        assert_eq!(i.rows.len(), 1);
9189        assert_eq!(i.rows[0].len(), 5);
9190    }
9191
9192    #[test]
9193    fn insert_missing_into_errors() {
9194        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
9195    }
9196
9197    #[test]
9198    fn create_table_round_trip() {
9199        let original =
9200            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
9201        let text = original.to_string();
9202        let again = parse_statement(&text).expect("re-parse");
9203        assert_eq!(original, again);
9204    }
9205
9206    #[test]
9207    fn insert_round_trip_with_negation_and_string() {
9208        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
9209        let text = original.to_string();
9210        let again = parse_statement(&text).expect("re-parse");
9211        assert_eq!(original, again);
9212    }
9213
9214    #[test]
9215    fn unknown_keyword_at_statement_start_errors() {
9216        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
9217        // the top-level dispatch still has no branch to take.
9218        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
9219        assert!(err.message.contains("expected SELECT"));
9220    }
9221
9222    // --- v0.8 CREATE INDEX --------------------------------------------------
9223
9224    #[test]
9225    fn create_index_basic() {
9226        let s = parse("CREATE INDEX idx_id ON users (id)");
9227        let Statement::CreateIndex(c) = s else {
9228            panic!("expected CreateIndex")
9229        };
9230        assert_eq!(c.name, "idx_id");
9231        assert_eq!(c.table, "users");
9232        assert_eq!(c.column, "id");
9233    }
9234
9235    #[test]
9236    fn create_index_missing_on_errors() {
9237        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
9238    }
9239
9240    #[test]
9241    fn create_index_missing_paren_errors() {
9242        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
9243    }
9244
9245    #[test]
9246    fn create_index_round_trip() {
9247        let original = parse("CREATE INDEX by_name ON users (name)");
9248        let again = parse_statement(&original.to_string()).unwrap();
9249        assert_eq!(original, again);
9250    }
9251
9252    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
9253
9254    #[test]
9255    fn create_unique_index_basic() {
9256        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
9257        let Statement::CreateIndex(c) = s else {
9258            panic!("expected CreateIndex");
9259        };
9260        assert!(c.is_unique);
9261        assert_eq!(c.column, "a");
9262        assert!(c.partial_predicate.is_none());
9263    }
9264
9265    #[test]
9266    fn create_unique_index_partial() {
9267        // mailrs's email_templates "one default per user" shape.
9268        let s = parse(
9269            "CREATE UNIQUE INDEX idx_email_templates_user_default \
9270             ON email_templates (user_address) WHERE is_default = true",
9271        );
9272        let Statement::CreateIndex(c) = s else {
9273            panic!("expected CreateIndex");
9274        };
9275        assert!(c.is_unique);
9276        assert_eq!(c.table, "email_templates");
9277        assert_eq!(c.column, "user_address");
9278        assert!(c.partial_predicate.is_some());
9279    }
9280
9281    #[test]
9282    fn create_unique_index_composite_with_predicate() {
9283        // mailrs's calendar_events instance: composite columns.
9284        let s = parse(
9285            "CREATE UNIQUE INDEX uq_calendar_events_instance \
9286             ON calendar_events (calendar_id, uid, recurrence_id) \
9287             WHERE recurrence_id IS NOT NULL",
9288        );
9289        let Statement::CreateIndex(c) = s else {
9290            panic!("expected CreateIndex");
9291        };
9292        assert!(c.is_unique);
9293        assert_eq!(c.column, "calendar_id");
9294        assert_eq!(
9295            c.extra_columns,
9296            vec!["uid".to_string(), "recurrence_id".to_string()]
9297        );
9298        assert!(c.partial_predicate.is_some());
9299    }
9300
9301    #[test]
9302    fn create_unique_index_using_btree_ok() {
9303        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
9304        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
9305    }
9306
9307    #[test]
9308    fn create_unique_index_using_hnsw_rejected() {
9309        let err =
9310            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
9311        assert!(err.message.contains("UNIQUE"), "{}", err.message);
9312    }
9313
9314    #[test]
9315    fn create_unique_index_round_trip() {
9316        let original = parse(
9317            "CREATE UNIQUE INDEX uq_calendar_events_master \
9318             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
9319        );
9320        let again = parse_statement(&original.to_string()).unwrap();
9321        assert_eq!(original, again);
9322    }
9323
9324    #[test]
9325    fn create_unique_without_index_errors() {
9326        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
9327        assert!(err.message.contains("INDEX"), "{}", err.message);
9328    }
9329
9330    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
9331
9332    #[test]
9333    fn create_table_bytea_column() {
9334        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
9335        let Statement::CreateTable(c) = s else {
9336            panic!("expected CreateTable");
9337        };
9338        assert_eq!(c.columns.len(), 2);
9339        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
9340        assert!(!c.columns[1].nullable);
9341    }
9342
9343    #[test]
9344    fn create_table_bytes_alias_column() {
9345        let s = parse("CREATE TABLE t (blob BYTES)");
9346        let Statement::CreateTable(c) = s else {
9347            panic!("expected CreateTable");
9348        };
9349        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
9350    }
9351
9352    #[test]
9353    fn bytea_round_trip_display() {
9354        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
9355        let again = parse_statement(&original.to_string()).unwrap();
9356        assert_eq!(original, again);
9357    }
9358
9359    // --- v0.9 transactions -------------------------------------------------
9360
9361    #[test]
9362    fn begin_commit_rollback_parse_as_unit_variants() {
9363        assert_eq!(parse("BEGIN"), Statement::Begin);
9364        assert_eq!(parse("COMMIT"), Statement::Commit);
9365        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
9366        // Trailing semicolons accepted too.
9367        assert_eq!(parse("BEGIN;"), Statement::Begin);
9368    }
9369
9370    // --- v1.2: pgvector distance ops + ::vector cast --------------------
9371
9372    #[test]
9373    fn inner_product_binop_parses() {
9374        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
9375        let Statement::Select(s) = s else { panic!() };
9376        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9377            panic!()
9378        };
9379        assert!(matches!(
9380            expr,
9381            Expr::Binary {
9382                op: BinOp::InnerProduct,
9383                ..
9384            }
9385        ));
9386    }
9387
9388    #[test]
9389    fn cosine_distance_binop_parses() {
9390        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
9391        let Statement::Select(s) = s else { panic!() };
9392        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9393            panic!()
9394        };
9395        assert!(matches!(
9396            expr,
9397            Expr::Binary {
9398                op: BinOp::CosineDistance,
9399                ..
9400            }
9401        ));
9402    }
9403
9404    #[test]
9405    fn vector_cast_postfix_wraps_string_literal() {
9406        let s = parse("SELECT '[1,2,3]'::vector FROM t");
9407        let Statement::Select(s) = s else { panic!() };
9408        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9409            panic!()
9410        };
9411        assert!(matches!(
9412            expr,
9413            Expr::Cast {
9414                target: CastTarget::Vector,
9415                ..
9416            }
9417        ));
9418    }
9419
9420    #[test]
9421    fn unsupported_cast_target_errors() {
9422        // `::numeric` isn't in the v1.3 cast target set.
9423        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
9424        assert!(err.message.contains("unsupported cast target"));
9425    }
9426
9427    #[test]
9428    fn tx_statements_round_trip() {
9429        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
9430            let original = parse(q);
9431            let again = parse_statement(&original.to_string()).unwrap();
9432            assert_eq!(original, again);
9433        }
9434    }
9435
9436    #[test]
9437    fn interval_text_parsing_units() {
9438        // Single unit.
9439        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
9440        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
9441        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
9442        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
9443        // Compound spans accumulate.
9444        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
9445        assert_eq!(
9446            parse_interval_text("1 day 2 hours"),
9447            Some((0, 86_400_000_000 + 7_200_000_000))
9448        );
9449        // Negative numbers carry through.
9450        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
9451        // Bad shapes return None.
9452        assert_eq!(parse_interval_text(""), None);
9453        assert_eq!(parse_interval_text("garbage"), None);
9454        assert_eq!(parse_interval_text("1 fortnight"), None);
9455        assert_eq!(parse_interval_text("1"), None);
9456    }
9457
9458    #[test]
9459    fn interval_literal_roundtrips_via_display() {
9460        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
9461        let s = parsed.to_string();
9462        // Display preserves the original text verbatim.
9463        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
9464        // And re-parsing yields a structurally equal statement.
9465        let again = parse_statement(&s).unwrap();
9466        assert_eq!(parsed, again);
9467    }
9468
9469    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
9470
9471    #[test]
9472    fn parser_recognises_create_publication_bare() {
9473        let s = parse("CREATE PUBLICATION pub_a");
9474        let Statement::CreatePublication(p) = s else {
9475            panic!("expected CreatePublication, got {s:?}")
9476        };
9477        assert_eq!(p.name, "pub_a");
9478        assert_eq!(p.scope, PublicationScope::AllTables);
9479    }
9480
9481    #[test]
9482    fn parser_recognises_create_publication_for_all_tables() {
9483        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
9484        let Statement::CreatePublication(p) = s else {
9485            panic!("expected CreatePublication, got {s:?}")
9486        };
9487        assert_eq!(p.name, "pub_a");
9488        assert_eq!(p.scope, PublicationScope::AllTables);
9489    }
9490
9491    #[test]
9492    fn parser_recognises_drop_publication() {
9493        let s = parse("DROP PUBLICATION pub_a");
9494        let Statement::DropPublication(name) = s else {
9495            panic!("expected DropPublication, got {s:?}")
9496        };
9497        assert_eq!(name, "pub_a");
9498    }
9499
9500    #[test]
9501    fn parser_recognises_for_table_list() {
9502        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
9503        let Statement::CreatePublication(p) = s else {
9504            panic!("expected CreatePublication, got {s:?}")
9505        };
9506        assert_eq!(p.name, "pub_a");
9507        let PublicationScope::ForTables(ts) = p.scope else {
9508            panic!("expected ForTables scope")
9509        };
9510        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
9511    }
9512
9513    #[test]
9514    fn parser_recognises_for_tables_plural() {
9515        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
9516        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
9517        let Statement::CreatePublication(p) = s else {
9518            panic!("expected CreatePublication, got {s:?}")
9519        };
9520        let PublicationScope::ForTables(ts) = p.scope else {
9521            panic!("expected ForTables")
9522        };
9523        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9524    }
9525
9526    #[test]
9527    fn parser_recognises_for_all_tables_except_list() {
9528        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
9529        let Statement::CreatePublication(p) = s else {
9530            panic!()
9531        };
9532        let PublicationScope::AllTablesExcept(ts) = p.scope else {
9533            panic!("expected AllTablesExcept")
9534        };
9535        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9536    }
9537
9538    #[test]
9539    fn parser_rejects_for_table_with_empty_list() {
9540        // `FOR TABLE` with nothing after is a parse error.
9541        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
9542            .expect_err("must error on empty list");
9543        // No specific message asserted — the call falls through to
9544        // expect_ident_like which yields "expected identifier, got …".
9545        assert!(!err.message.is_empty());
9546    }
9547
9548    #[test]
9549    fn parser_recognises_show_publications() {
9550        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
9551        // bare ident in this position, NOT a reserved keyword.
9552        let s = parse("SHOW PUBLICATIONS");
9553        assert!(matches!(s, Statement::ShowPublications));
9554    }
9555
9556    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
9557
9558    #[test]
9559    fn parser_recognises_create_subscription_single_publication() {
9560        let s = parse(
9561            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
9562        );
9563        let Statement::CreateSubscription(c) = s else {
9564            panic!("expected CreateSubscription, got {s:?}")
9565        };
9566        assert_eq!(c.name, "sub_a");
9567        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
9568        assert_eq!(c.publications, alloc::vec!["pub_a"]);
9569    }
9570
9571    #[test]
9572    fn parser_recognises_create_subscription_multi_publication() {
9573        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
9574        let Statement::CreateSubscription(c) = s else {
9575            panic!()
9576        };
9577        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
9578    }
9579
9580    #[test]
9581    fn parser_rejects_create_subscription_missing_connection() {
9582        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
9583            .expect_err("must error on missing CONNECTION");
9584        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
9585    }
9586
9587    #[test]
9588    fn parser_rejects_create_subscription_missing_publication() {
9589        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
9590            .expect_err("must error on missing PUBLICATION");
9591        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
9592    }
9593
9594    #[test]
9595    fn parser_recognises_drop_subscription() {
9596        let s = parse("DROP SUBSCRIPTION sub_a");
9597        let Statement::DropSubscription(name) = s else {
9598            panic!("expected DropSubscription, got {s:?}")
9599        };
9600        assert_eq!(name, "sub_a");
9601    }
9602
9603    #[test]
9604    fn parser_recognises_show_subscriptions() {
9605        let s = parse("SHOW SUBSCRIPTIONS");
9606        assert!(matches!(s, Statement::ShowSubscriptions));
9607    }
9608
9609    #[test]
9610    fn parser_recognises_wait_for_wal_position_no_timeout() {
9611        let s = parse("WAIT FOR WAL POSITION 12345");
9612        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9613            panic!("expected WaitForWalPosition, got {s:?}")
9614        };
9615        assert_eq!(pos, 12345);
9616        assert!(timeout_ms.is_none());
9617    }
9618
9619    #[test]
9620    fn parser_recognises_wait_for_wal_position_with_timeout() {
9621        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
9622        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9623            panic!()
9624        };
9625        assert_eq!(pos, 67890);
9626        assert_eq!(timeout_ms, Some(5000));
9627    }
9628
9629    #[test]
9630    fn parser_rejects_wait_with_negative_position() {
9631        // The lexer treats `-` as a token; `expect_u64_literal`
9632        // only sees the Integer that follows, so the negative
9633        // arrives as a unary-minus expression at higher levels.
9634        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
9635        // parse error one way or another.
9636        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
9637        assert!(!err.message.is_empty());
9638    }
9639
9640    #[test]
9641    fn parser_recognises_bare_analyze() {
9642        let s = parse("ANALYZE");
9643        assert!(matches!(s, Statement::Analyze(None)));
9644    }
9645
9646    #[test]
9647    fn parser_recognises_analyze_with_table() {
9648        let s = parse("ANALYZE users");
9649        let Statement::Analyze(Some(name)) = s else {
9650            panic!("expected Analyze, got {s:?}")
9651        };
9652        assert_eq!(name, "users");
9653    }
9654
9655    #[test]
9656    fn parser_recognises_analyze_with_quoted_table() {
9657        let s = parse("ANALYZE \"Mixed Case\"");
9658        let Statement::Analyze(Some(name)) = s else {
9659            panic!()
9660        };
9661        assert_eq!(name, "Mixed Case");
9662    }
9663
9664    #[test]
9665    fn parser_rejects_analyze_with_garbage_token() {
9666        let err = parse_statement("ANALYZE 42").expect_err("must error");
9667        assert!(!err.message.is_empty());
9668    }
9669
9670    #[test]
9671    fn analyze_display_roundtrips() {
9672        for sql in ["ANALYZE", "ANALYZE users"] {
9673            let s = parse(sql);
9674            let printed = s.to_string();
9675            let again = parse_statement(&printed)
9676                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9677            assert_eq!(s, again);
9678        }
9679    }
9680
9681    #[test]
9682    fn wait_for_display_roundtrips() {
9683        for sql in [
9684            "WAIT FOR WAL POSITION 12345",
9685            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
9686        ] {
9687            let s = parse(sql);
9688            let printed = s.to_string();
9689            let again = parse_statement(&printed)
9690                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9691            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9692        }
9693    }
9694
9695    #[test]
9696    fn subscription_ddl_display_roundtrips() {
9697        for sql in [
9698            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
9699            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
9700            "DROP SUBSCRIPTION sub_a",
9701            "SHOW SUBSCRIPTIONS",
9702        ] {
9703            let s = parse(sql);
9704            let printed = s.to_string();
9705            let again = parse_statement(&printed)
9706                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9707            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9708        }
9709    }
9710
9711    #[test]
9712    fn parser_drop_dispatches_user_vs_publication() {
9713        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
9714        // tokenises DROP. Both targets must still parse.
9715        let s = parse("DROP USER 'alice'");
9716        let Statement::DropUser(name) = s else {
9717            panic!("expected DropUser, got {s:?}")
9718        };
9719        assert_eq!(name, "alice");
9720        // And DROP PUBLICATION lands the new variant.
9721        let s = parse("DROP PUBLICATION p1");
9722        assert!(matches!(s, Statement::DropPublication(_)));
9723    }
9724
9725    #[test]
9726    fn publication_ddl_display_roundtrips() {
9727        // Every CREATE PUBLICATION variant must Display → parse →
9728        // same AST. v6.1.3 covers all three scope shapes.
9729        for sql in [
9730            "CREATE PUBLICATION pub_a",
9731            "CREATE PUBLICATION pub_a FOR ALL TABLES",
9732            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
9733            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
9734            "DROP PUBLICATION pub_a",
9735            "SHOW PUBLICATIONS",
9736        ] {
9737            let s = parse(sql);
9738            let printed = s.to_string();
9739            let again = parse_statement(&printed)
9740                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9741            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9742        }
9743    }
9744
9745    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
9746
9747    #[test]
9748    fn create_function_returns_trigger_plpgsql_minimal() {
9749        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
9750        let s = parse(sql);
9751        let Statement::CreateFunction(f) = s else {
9752            panic!("expected CreateFunction");
9753        };
9754        assert_eq!(f.name, "noop");
9755        assert!(!f.or_replace);
9756        assert!(f.args.is_empty());
9757        assert!(matches!(f.returns, FunctionReturn::Trigger));
9758        assert_eq!(f.language, "plpgsql");
9759        let FunctionBody::PlPgSql(block) = f.body else {
9760            panic!("expected PlPgSql body");
9761        };
9762        assert_eq!(block.statements.len(), 1);
9763        assert!(matches!(
9764            block.statements[0],
9765            PlPgSqlStmt::Return(ReturnTarget::New)
9766        ));
9767    }
9768
9769    #[test]
9770    fn create_function_or_replace_with_assignment() {
9771        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
9772        // RETURN NEW.
9773        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
9774BEGIN
9775  NEW.search_vector := to_tsvector('english', NEW.subject);
9776  RETURN NEW;
9777END;
9778$$";
9779        let s = parse(sql);
9780        let Statement::CreateFunction(f) = s else {
9781            panic!("expected CreateFunction");
9782        };
9783        assert!(f.or_replace);
9784        let FunctionBody::PlPgSql(block) = &f.body else {
9785            panic!("expected PlPgSql body");
9786        };
9787        assert_eq!(block.statements.len(), 2);
9788        // First statement: NEW.search_vector := to_tsvector(...)
9789        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
9790            panic!("expected Assign as first stmt");
9791        };
9792        match target {
9793            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
9794            other => panic!("expected NEW.col, got {other:?}"),
9795        }
9796        // Second statement: RETURN NEW
9797        assert!(matches!(
9798            block.statements[1],
9799            PlPgSqlStmt::Return(ReturnTarget::New)
9800        ));
9801    }
9802
9803    #[test]
9804    fn create_trigger_after_insert_or_update() {
9805        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
9806        let s = parse(sql);
9807        let Statement::CreateTrigger(t) = s else {
9808            panic!("expected CreateTrigger");
9809        };
9810        assert_eq!(t.name, "tg");
9811        assert_eq!(t.table, "messages");
9812        assert_eq!(t.timing, TriggerTiming::After);
9813        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
9814        assert_eq!(t.for_each, TriggerForEach::Row);
9815        assert_eq!(t.function, "update_sv");
9816    }
9817
9818    #[test]
9819    fn create_trigger_before_delete_execute_procedure_alias() {
9820        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
9821        let sql =
9822            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
9823        let s = parse(sql);
9824        let Statement::CreateTrigger(t) = s else {
9825            panic!("expected CreateTrigger");
9826        };
9827        assert_eq!(t.timing, TriggerTiming::Before);
9828        assert_eq!(t.events, vec![TriggerEvent::Delete]);
9829    }
9830
9831    #[test]
9832    fn drop_trigger_if_exists_round_trips() {
9833        // No parser support for DROP TRIGGER yet — added in v7.12.5
9834        // alongside the broader DROP …{IF EXISTS} cleanup. The
9835        // AST + Display impls are in place so we round-trip via
9836        // construction:
9837        let s = Statement::DropTrigger {
9838            name: "tg".into(),
9839            table: "messages".into(),
9840            if_exists: true,
9841        };
9842        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
9843    }
9844
9845    #[test]
9846    fn trigger_ddl_display_roundtrips_through_parser() {
9847        // CREATE TRIGGER + its referenced CREATE FUNCTION must
9848        // Display → parse → same AST (modulo PL/pgSQL body
9849        // formatting which is parser-canonicalised).
9850        for sql in [
9851            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
9852            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
9853        ] {
9854            let s = parse(sql);
9855            let printed = s.to_string();
9856            let again = parse_statement(&printed)
9857                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
9858            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
9859        }
9860    }
9861}