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. PG string semantics.
154pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
155    parse_statement_with(input, false)
156}
157
158/// v7.22 (round-13 T3) — dialect-aware entry: `backslash_escapes`
159/// selects MySQL-style string lexing (see `lexer::tokenize_with`).
160/// The engine threads its session flag through here.
161pub fn parse_statement_with(input: &str, backslash_escapes: bool) -> Result<Statement, ParseError> {
162    let tokens = lexer::tokenize_with(input, backslash_escapes)?;
163    let mut p = Parser::new(tokens);
164    let stmt = p.parse_one_statement()?;
165    if matches!(p.peek(), Token::Semicolon) {
166        p.advance();
167    }
168    p.expect_eof()?;
169    Ok(stmt)
170}
171
172struct Parser {
173    tokens: Vec<Token>,
174    pos: usize,
175}
176
177/// v7.22 (round-13 gap 5) — the kind keyword after `CONSTRAINT
178/// <name>` in a CREATE TABLE column list. FOREIGN KEY is not here:
179/// it keeps its dedicated path (`parse_table_level_fk`).
180enum NamedTableConstraintKind {
181    Check,
182    Unique,
183    PrimaryKey,
184}
185
186impl Parser {
187    fn new(tokens: Vec<Token>) -> Self {
188        Self { tokens, pos: 0 }
189    }
190
191    fn peek(&self) -> &Token {
192        // tokens always ends with Eof; pos is clamped in advance().
193        &self.tokens[self.pos]
194    }
195
196    fn advance(&mut self) -> Token {
197        let t = mem::replace(&mut self.tokens[self.pos], Token::Eof);
198        if self.pos + 1 < self.tokens.len() {
199            self.pos += 1;
200        }
201        t
202    }
203
204    fn err(&self, message: String) -> ParseError {
205        ParseError {
206            message,
207            token_pos: self.pos,
208        }
209    }
210
211    fn expect_eof(&self) -> Result<(), ParseError> {
212        if matches!(self.peek(), Token::Eof) {
213            Ok(())
214        } else {
215            Err(self.err(format!("expected end of input, got {:?}", self.peek())))
216        }
217    }
218
219    /// v7.14.0 — swallow every token up to (but not including) the
220    /// next semicolon / EOF. Used by the dump-noise dispatcher
221    /// to consume `COMMENT ON …`, `GRANT …`, `LOCK TABLES …`,
222    /// etc. without modeling each grammar.
223    fn consume_until_statement_boundary(&mut self) {
224        loop {
225            match self.peek() {
226                Token::Semicolon | Token::Eof => return,
227                _ => self.advance(),
228            };
229        }
230    }
231
232    /// v7.22 (round-13 T2) — consume to the statement boundary like
233    /// `consume_until_statement_boundary`, but pick out the sequence
234    /// name on the way: either `SEQUENCE NAME <ident>` (identity
235    /// columns) or the first string literal (`nextval('<seq>')`).
236    /// Schema qualifiers and `::regclass` casts are stripped.
237    fn scan_sequence_name_until_boundary(&mut self) -> Option<String> {
238        let mut seq: Option<String> = None;
239        let mut after_sequence_kw = false;
240        let mut after_name_kw = false;
241        loop {
242            match self.peek().clone() {
243                Token::Semicolon | Token::Eof => break,
244                Token::Ident(s) | Token::QuotedIdent(s) => {
245                    if after_name_kw && seq.is_none() {
246                        self.advance();
247                        let mut name = s;
248                        // `SEQUENCE NAME public.groups_id_seq` — keep
249                        // the bare name, drop qualifiers.
250                        while matches!(self.peek(), Token::Dot) {
251                            self.advance();
252                            if let Token::Ident(n) | Token::QuotedIdent(n) = self.advance() {
253                                name = n;
254                            }
255                        }
256                        seq = Some(name);
257                        after_name_kw = false;
258                        continue;
259                    }
260                    if after_sequence_kw && s.eq_ignore_ascii_case("name") {
261                        after_name_kw = true;
262                        after_sequence_kw = false;
263                    } else {
264                        after_sequence_kw = s.eq_ignore_ascii_case("sequence");
265                    }
266                    self.advance();
267                }
268                Token::String(s) => {
269                    if seq.is_none() {
270                        // `nextval('public.groups_id_seq'::regclass)`
271                        let bare = s
272                            .rsplit_once('.')
273                            .map_or_else(|| s.clone(), |(_, b)| b.to_string());
274                        seq = Some(bare);
275                    }
276                    self.advance();
277                }
278                _ => {
279                    after_sequence_kw = false;
280                    after_name_kw = false;
281                    self.advance();
282                }
283            }
284        }
285        seq
286    }
287
288    fn expect_ident_like(&mut self) -> Result<String, ParseError> {
289        let first = match self.advance() {
290            Token::Ident(s) | Token::QuotedIdent(s) => s,
291            other => {
292                return Err(ParseError {
293                    message: format!("expected identifier, got {other:?}"),
294                    token_pos: self.pos.saturating_sub(1),
295                });
296            }
297        };
298        // v7.14.0 — strip optional `<schema>.` prefix. PG dumps
299        // qualify every name with `public.` (and pg_catalog.* for
300        // functions); SPG is single-schema so we discard the
301        // prefix and return only the trailing ident. Same shape
302        // also handles MySQL `db.tbl` cross-database refs (SPG
303        // ignores the db part).
304        if matches!(self.peek(), Token::Dot) {
305            self.advance();
306            match self.advance() {
307                Token::Ident(s) | Token::QuotedIdent(s) => return Ok(s),
308                other => {
309                    return Err(ParseError {
310                        message: format!("expected identifier after '{first}.', got {other:?}"),
311                        token_pos: self.pos.saturating_sub(1),
312                    });
313                }
314            }
315        }
316        Ok(first)
317    }
318
319    #[allow(clippy::too_many_lines)]
320    fn parse_one_statement(&mut self) -> Result<Statement, ParseError> {
321        // v7.14.0 — empty / comment-only / semicolon-only input
322        // (after the lexer strips line + block + MySQL
323        // conditional comments) lands as Statement::Empty.
324        // pg_dump and mysqldump emit several wrappers that
325        // collapse to nothing after stripping (`/*!40101 SET …
326        // */;`, blank lines between statements); the engine
327        // returns CommandOk no-op so the dump loads cleanly.
328        if matches!(self.peek(), Token::Eof | Token::Semicolon) {
329            return Ok(Statement::Empty);
330        }
331        // v7.14.0 — pg_dump / mysqldump "noise" statements:
332        // catalog / metadata DDL that has no behavioural effect
333        // on SPG's single-schema, single-database, single-user
334        // model. Consume the whole statement up to the next
335        // semicolon / EOF and return Empty. This is broader than
336        // the per-keyword DROP / SET / COMMENT arms but lets the
337        // long tail of `LOCK TABLES`, `UNLOCK TABLES`, `GRANT`,
338        // `REVOKE`, `ALTER OWNER TO`, `\restrict`, `\unrestrict`,
339        // `BEGIN; COMMIT;` wrappers, etc. all pass through.
340        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
341            let lc = s.to_ascii_lowercase();
342            if is_dump_noise_statement(&lc) {
343                self.consume_until_statement_boundary();
344                return Ok(Statement::Empty);
345            }
346        }
347        match self.peek() {
348            Token::Select => self.parse_select_stmt(),
349            // v7.9.27 — `DO $$ … $$ [LANGUAGE plpgsql]`. The
350            // body is a dollar-quoted plpgsql block (lexer already
351            // collapsed `$$…$$` into a single Token::String).
352            // v7.16.2 — mailrs round-10 A.2: parse the body as a
353            // real PlPgSqlBlock so the engine can EXECUTE it at
354            // top level instead of silently swallowing. Pre-
355            // v7.16.2 the parser threw the body away and the
356            // engine returned CommandOk for the entire DO; that
357            // turned `DO BEGIN … IF EXISTS ... THEN ALTER …; END
358            // $$` into a SEV-1 silent no-op (the IF + the rename
359            // were both invisible — mailrs's migrate-042 didn't
360            // actually run). Now the body parses + executes;
361            // EmbeddedSql inside the block runs immediately
362            // against the engine (not deferred — we're at top
363            // level, not inside a trigger row-write loop).
364            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
365                self.advance();
366                let body_text = match self.advance() {
367                    Token::String(s) => s,
368                    other => {
369                        return Err(self.err(alloc::format!(
370                            "expected dollar-quoted body after DO, got {other:?}"
371                        )));
372                    }
373                };
374                // Optional `LANGUAGE <name>` trailer (idents only).
375                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("language")) {
376                    self.advance();
377                    let _ = self.expect_ident_like()?;
378                }
379                // Parse the body — same shape CREATE FUNCTION
380                // uses for trigger function bodies. If the body
381                // doesn't parse cleanly we surface the error
382                // (better than silent no-op).
383                let block = parse_plpgsql_body(&body_text)?;
384                Ok(Statement::DoBlock(block))
385            }
386            // v4.11: `WITH name AS (SELECT ...) [, ...] SELECT ...`.
387            // WITH isn't a reserved token in our lexer — comes through
388            // as `Token::Ident("with")` (case-insensitive).
389            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with") => {
390                self.advance();
391                self.parse_with_cte_then_select()
392            }
393            // v4.26: `EXPLAIN [ANALYZE] <select>`. Comes through as
394            // an identifier — not a reserved keyword.
395            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("explain") => {
396                self.advance();
397                let mut analyze = false;
398                let mut suggest = false;
399                // v6.8.3 — `EXPLAIN (SUGGEST)` opt-in.
400                if matches!(self.peek(), Token::LParen) {
401                    self.advance();
402                    let opt = match self.peek().clone() {
403                        Token::Ident(s) | Token::QuotedIdent(s) => s,
404                        other => {
405                            return Err(self.err(format!(
406                                "expected option keyword inside EXPLAIN (…), got {other:?}"
407                            )));
408                        }
409                    };
410                    if !opt.eq_ignore_ascii_case("suggest") {
411                        return Err(self.err(format!(
412                            "unknown EXPLAIN option {opt:?}; v6.8.3 supports SUGGEST"
413                        )));
414                    }
415                    self.advance();
416                    if !matches!(self.peek(), Token::RParen) {
417                        return Err(self.err(format!(
418                            "expected ')' after EXPLAIN option, got {:?}",
419                            self.peek()
420                        )));
421                    }
422                    self.advance();
423                    suggest = true;
424                } else if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
425                    && (s.eq_ignore_ascii_case("analyze") || s.eq_ignore_ascii_case("analyse"))
426                {
427                    self.advance();
428                    analyze = true;
429                }
430                let inner = self.parse_select_stmt()?;
431                let Statement::Select(s) = inner else {
432                    return Err(self.err(format!("EXPLAIN body must be a SELECT, got {inner:?}")));
433                };
434                Ok(Statement::Explain(crate::ast::ExplainStatement {
435                    analyze,
436                    inner: Box::new(s),
437                    suggest,
438                }))
439            }
440            Token::Create => self.parse_create_stmt(),
441            Token::Insert => self.parse_insert_stmt(),
442            Token::Begin => {
443                self.advance();
444                Ok(Statement::Begin)
445            }
446            Token::Commit => {
447                self.advance();
448                Ok(Statement::Commit)
449            }
450            Token::Rollback => {
451                self.advance();
452                // `ROLLBACK TO [SAVEPOINT] <name>` returns to that
453                // savepoint without ending the transaction. Bare
454                // `ROLLBACK` drops the whole TX.
455                if matches!(self.peek(), Token::To) {
456                    self.advance();
457                    if matches!(self.peek(), Token::Savepoint) {
458                        self.advance();
459                    }
460                    let name = self.expect_ident_like()?;
461                    Ok(Statement::RollbackToSavepoint(name))
462                } else {
463                    Ok(Statement::Rollback)
464                }
465            }
466            Token::Savepoint => {
467                self.advance();
468                let name = self.expect_ident_like()?;
469                Ok(Statement::Savepoint(name))
470            }
471            Token::Release => {
472                self.advance();
473                // `RELEASE [SAVEPOINT] <name>` — the `SAVEPOINT` keyword
474                // is optional in standard SQL.
475                if matches!(self.peek(), Token::Savepoint) {
476                    self.advance();
477                }
478                let name = self.expect_ident_like()?;
479                Ok(Statement::ReleaseSavepoint(name))
480            }
481            Token::Show => {
482                self.advance();
483                // `SHOW TABLES` / `SHOW USERS` / `SHOW COLUMNS FROM <table>`.
484                // v6.1.2 promoted TABLES to a reserved keyword (for
485                // `CREATE PUBLICATION … FOR ALL TABLES`), so it now
486                // arrives as `Token::Tables` rather than a bare ident.
487                // USERS / COLUMNS remain bare idents.
488                let target = match self.advance() {
489                    Token::Tables => "tables".to_string(),
490                    // v7.17.0 Phase 3.P0-59 — CREATE is a reserved
491                    // keyword token; recognise it as the SHOW CREATE
492                    // dispatch keyword too.
493                    Token::Create => "create".to_string(),
494                    // v7.17.0 Phase 3.P0-60 — INDEX is a reserved
495                    // keyword too; let SHOW INDEX FROM parse.
496                    Token::Index => "index".to_string(),
497                    Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
498                    other => {
499                        return Err(self.err(format!(
500                            "expected SHOW target, got {other:?}"
501                        )));
502                    }
503                };
504                match target.as_str() {
505                    "tables" => Ok(Statement::ShowTables),
506                    "users" => Ok(Statement::ShowUsers),
507                    // v7.17.0 Phase 3.P0-59 — MySQL `SHOW CREATE
508                    // TABLE <t>` returns a 2-column row: (Table,
509                    // Create Table). mysqldump emits this for every
510                    // table at scrape time; without it the dump
511                    // round-trip stalls.
512                    // v7.17.0 Phase 3.P0-60 — MySQL `SHOW INDEXES
513                    // FROM <t>` (also spelled `SHOW INDEX` and
514                    // `SHOW KEYS`). admin / mysqldump probes use
515                    // it to list per-table indexes.
516                    "indexes" | "index" | "keys" => {
517                        if !matches!(self.peek(), Token::From) {
518                            return Err(self.err(format!(
519                                "expected FROM after SHOW INDEXES, got {:?}",
520                                self.peek()
521                            )));
522                        }
523                        self.advance();
524                        let table = self.expect_ident_like()?;
525                        Ok(Statement::ShowIndexes(table))
526                    }
527                    // v7.17.0 Phase 3.P0-61 — MySQL `SHOW STATUS` /
528                    // `SHOW VARIABLES`. Both return a 2-column row
529                    // set listing server-side state; clients probe
530                    // them at connect time.
531                    "status" => Ok(Statement::ShowStatus),
532                    "variables" => Ok(Statement::ShowVariables),
533                    // v7.17.0 Phase 3.P0-62 — MySQL `SHOW PROCESSLIST`.
534                    "processlist" => Ok(Statement::ShowProcesslist),
535                    "create" => {
536                        // SHOW CREATE TABLE / VIEW / DATABASE — only
537                        // TABLE is supported in v7.17.
538                        let kind = match self.advance() {
539                            Token::Ident(s) | Token::QuotedIdent(s) => s,
540                            Token::Table => "table".to_string(),
541                            other => {
542                                return Err(self.err(format!(
543                                    "expected TABLE after SHOW CREATE, got {other:?}"
544                                )));
545                            }
546                        };
547                        if !kind.eq_ignore_ascii_case("table") {
548                            return Err(self.err(format!(
549                                "unsupported SHOW CREATE {kind:?}; v7.17 supports TABLE only"
550                            )));
551                        }
552                        let name = self.expect_ident_like()?;
553                        Ok(Statement::ShowCreateTable(name))
554                    }
555                    // v7.17.0 Phase 3.P0-58 — MySQL `SHOW DATABASES`
556                    // (and `SHOW SCHEMAS` alias). The mysql client uses
557                    // it to populate the database selector at connect
558                    // time; without it `mysql -p` errors before the
559                    // first user query.
560                    "databases" | "schemas" => Ok(Statement::ShowDatabases),
561                    // v6.1.3 — PUBLICATIONS plural is NOT a reserved
562                    // keyword on its own; it lands here as a bare
563                    // ident. Returning all publications + their
564                    // scope summary.
565                    "publications" => Ok(Statement::ShowPublications),
566                    // v6.1.4 — same shape for SUBSCRIPTIONS plural.
567                    "subscriptions" => Ok(Statement::ShowSubscriptions),
568                    "columns" => {
569                        if !matches!(self.peek(), Token::From) {
570                            return Err(self.err(format!(
571                                "expected FROM after SHOW COLUMNS, got {:?}",
572                                self.peek()
573                            )));
574                        }
575                        self.advance();
576                        let table = self.expect_ident_like()?;
577                        Ok(Statement::ShowColumns(table))
578                    }
579                    other => Err(self.err(format!(
580                        "unknown SHOW target {other:?}; supported: TABLES, COLUMNS, USERS, PUBLICATIONS"
581                    ))),
582                }
583            }
584            // v6.1.2: `DROP` is now a reserved keyword (it dispatches
585            // to DROP USER and DROP PUBLICATION today; DROP TABLE /
586            // DROP INDEX are still SHOW-shaped admin ops). Pre-6.1.2
587            // arrived as a bare ident; tokenising it dedicatedly
588            // keeps the dispatch tree small.
589            Token::Drop => {
590                self.advance();
591                match self.peek() {
592                    Token::Publication => {
593                        self.advance();
594                        let name = self.expect_ident_or_string()?;
595                        Ok(Statement::DropPublication(name))
596                    }
597                    Token::Subscription => {
598                        self.advance();
599                        let name = self.expect_ident_or_string()?;
600                        Ok(Statement::DropSubscription(name))
601                    }
602                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
603                        self.advance();
604                        let name = self.expect_ident_or_string()?;
605                        Ok(Statement::DropUser(name))
606                    }
607                    // v7.12.4 — DROP TRIGGER [IF EXISTS] name ON table.
608                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
609                        self.advance();
610                        let if_exists = self.consume_if_exists();
611                        let name = self.expect_ident_like()?;
612                        // ON <table>
613                        if !matches!(self.peek(), Token::On) {
614                            return Err(self.err(alloc::format!(
615                                "expected ON <table> after DROP TRIGGER {name:?}, got {:?}",
616                                self.peek()
617                            )));
618                        }
619                        self.advance();
620                        let table = self.expect_ident_like()?;
621                        Ok(Statement::DropTrigger {
622                            name,
623                            table,
624                            if_exists,
625                        })
626                    }
627                    // v7.12.4 — DROP FUNCTION [IF EXISTS] name [(args)].
628                    // v7.12.4 ignores any optional arg-list (signature-
629                    // based overload disambiguation lands in v7.12.5+).
630                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
631                        self.advance();
632                        let if_exists = self.consume_if_exists();
633                        let name = self.expect_ident_like()?;
634                        // Optional `()` — consume + discard.
635                        if matches!(self.peek(), Token::LParen) {
636                            self.advance();
637                            // Skip until matching RParen, accepting any tokens (typed args we don't model yet).
638                            let mut depth = 1usize;
639                            while depth > 0 {
640                                match self.peek() {
641                                    Token::LParen => depth += 1,
642                                    Token::RParen => depth -= 1,
643                                    Token::Eof => {
644                                        return Err(self.err(alloc::format!(
645                                            "unterminated arg list in DROP FUNCTION {name:?}"
646                                        )));
647                                    }
648                                    _ => {}
649                                }
650                                self.advance();
651                            }
652                        }
653                        Ok(Statement::DropFunction { name, if_exists })
654                    }
655                    // v7.14.0 — DROP TABLE [IF EXISTS] name [, name…]
656                    // [CASCADE|RESTRICT]. pg_dump and mysqldump both
657                    // emit DROP TABLE IF EXISTS at the head of every
658                    // CREATE TABLE block so re-importing a dump
659                    // overwrites prior state. SPG accepts and removes
660                    // matching tables; CASCADE/RESTRICT trailers
661                    // accepted silently.
662                    Token::Table => {
663                        self.advance();
664                        let if_exists = self.consume_if_exists();
665                        let mut names: Vec<String> = Vec::new();
666                        loop {
667                            names.push(self.expect_ident_like()?);
668                            if matches!(self.peek(), Token::Comma) {
669                                self.advance();
670                                continue;
671                            }
672                            break;
673                        }
674                        if matches!(
675                            self.peek(),
676                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
677                                || s.eq_ignore_ascii_case("restrict")
678                        ) {
679                            self.advance();
680                        }
681                        Ok(Statement::DropTable { names, if_exists })
682                    }
683                    // v7.14.0 — DROP INDEX [IF EXISTS] name
684                    // [CASCADE|RESTRICT]. PG / mysqldump emit this
685                    // for partial-index renames and pgvector
686                    // migrations. SPG removes the matching index;
687                    // IF EXISTS makes the drop idempotent.
688                    Token::Index => {
689                        self.advance();
690                        let if_exists = self.consume_if_exists();
691                        let name = self.expect_ident_like()?;
692                        if matches!(
693                            self.peek(),
694                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
695                                || s.eq_ignore_ascii_case("restrict")
696                        ) {
697                            self.advance();
698                        }
699                        Ok(Statement::DropIndex { name, if_exists })
700                    }
701                    // v7.14.0 — DROP SCHEMA [IF EXISTS] name
702                    // [CASCADE|RESTRICT]. SPG is single-database;
703                    // v7.17.0 Phase 1.6 — DROP SCHEMA [IF EXISTS]
704                    // name [, name…] [CASCADE | RESTRICT]. Real
705                    // unregister (was silent no-op pre-v7.17).
706                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
707                        self.advance();
708                        let if_exists = self.consume_if_exists();
709                        let mut names = vec![self.expect_ident_like()?];
710                        while matches!(self.peek(), Token::Comma) {
711                            self.advance();
712                            names.push(self.expect_ident_like()?);
713                        }
714                        if matches!(
715                            self.peek(),
716                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
717                                || s.eq_ignore_ascii_case("restrict")
718                        ) {
719                            self.advance();
720                        }
721                        Ok(Statement::DropSchema { names, if_exists })
722                    }
723                    // v7.17.0 Phase 1.4 — DROP TYPE [IF EXISTS]
724                    // name [, name…] [CASCADE|RESTRICT].
725                    Token::Ident(s) | Token::QuotedIdent(s)
726                        if s.eq_ignore_ascii_case("type") =>
727                    {
728                        self.advance();
729                        let if_exists = self.consume_if_exists();
730                        let mut names = vec![self.expect_ident_like()?];
731                        while matches!(self.peek(), Token::Comma) {
732                            self.advance();
733                            names.push(self.expect_ident_like()?);
734                        }
735                        if matches!(
736                            self.peek(),
737                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
738                                || s.eq_ignore_ascii_case("restrict")
739                        ) {
740                            self.advance();
741                        }
742                        Ok(Statement::DropType { names, if_exists })
743                    }
744                    // v7.17.0 Phase 1.5 — DROP DOMAIN [IF EXISTS]
745                    // name [, name…] [CASCADE|RESTRICT].
746                    Token::Ident(s) | Token::QuotedIdent(s)
747                        if s.eq_ignore_ascii_case("domain") =>
748                    {
749                        self.advance();
750                        let if_exists = self.consume_if_exists();
751                        let mut names = vec![self.expect_ident_like()?];
752                        while matches!(self.peek(), Token::Comma) {
753                            self.advance();
754                            names.push(self.expect_ident_like()?);
755                        }
756                        if matches!(
757                            self.peek(),
758                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
759                                || s.eq_ignore_ascii_case("restrict")
760                        ) {
761                            self.advance();
762                        }
763                        Ok(Statement::DropDomain { names, if_exists })
764                    }
765                    // v7.17.0 Phase 1.3 — DROP MATERIALIZED VIEW
766                    // [IF EXISTS] name [, name…] [CASCADE|RESTRICT].
767                    Token::Ident(s) | Token::QuotedIdent(s)
768                        if s.eq_ignore_ascii_case("materialized") =>
769                    {
770                        self.advance();
771                        let nxt = self.peek().clone();
772                        if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
773                        {
774                            return Err(self.err(alloc::format!(
775                                "expected VIEW after DROP MATERIALIZED, got {nxt:?}"
776                            )));
777                        }
778                        self.advance();
779                        let if_exists = self.consume_if_exists();
780                        let mut names = vec![self.expect_ident_like()?];
781                        while matches!(self.peek(), Token::Comma) {
782                            self.advance();
783                            names.push(self.expect_ident_like()?);
784                        }
785                        if matches!(
786                            self.peek(),
787                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
788                                || s.eq_ignore_ascii_case("restrict")
789                        ) {
790                            self.advance();
791                        }
792                        Ok(Statement::DropMaterializedView { names, if_exists })
793                    }
794                    // v7.17.0 Phase 1.2 — DROP VIEW [IF EXISTS]
795                    // name [, name…] [CASCADE|RESTRICT].
796                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
797                        self.advance();
798                        let if_exists = self.consume_if_exists();
799                        let mut names = vec![self.expect_ident_like()?];
800                        while matches!(self.peek(), Token::Comma) {
801                            self.advance();
802                            names.push(self.expect_ident_like()?);
803                        }
804                        if matches!(
805                            self.peek(),
806                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
807                                || s.eq_ignore_ascii_case("restrict")
808                        ) {
809                            self.advance();
810                        }
811                        Ok(Statement::DropView { names, if_exists })
812                    }
813                    // v7.17.0 — DROP SEQUENCE [IF EXISTS] name [,name…]
814                    // [CASCADE|RESTRICT]. Real removal from catalog
815                    // (was a silent no-op pre-v7.17).
816                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
817                        self.advance();
818                        let if_exists = self.consume_if_exists();
819                        let mut names = vec![self.expect_ident_like()?];
820                        while matches!(self.peek(), Token::Comma) {
821                            self.advance();
822                            names.push(self.expect_ident_like()?);
823                        }
824                        if matches!(
825                            self.peek(),
826                            Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
827                                || s.eq_ignore_ascii_case("restrict")
828                        ) {
829                            self.advance();
830                        }
831                        Ok(Statement::DropSequence { names, if_exists })
832                    }
833                    other => Err(self.err(format!(
834                        "expected TABLE / INDEX / SCHEMA / SEQUENCE / USER / PUBLICATION / \
835                         SUBSCRIPTION / TRIGGER / FUNCTION after DROP, got {other:?}"
836                    ))),
837                }
838            }
839            // v7.17.0 Phase 1.3 — REFRESH MATERIALIZED VIEW name [WITH [NO] DATA].
840            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("refresh") => {
841                self.advance();
842                let nxt = self.peek().clone();
843                if !matches!(&nxt, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("materialized"))
844                {
845                    return Err(self.err(alloc::format!(
846                        "expected MATERIALIZED after REFRESH, got {nxt:?}"
847                    )));
848                }
849                self.advance();
850                let nxt2 = self.peek().clone();
851                if !matches!(&nxt2, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
852                {
853                    return Err(self.err(alloc::format!(
854                        "expected VIEW after REFRESH MATERIALIZED, got {nxt2:?}"
855                    )));
856                }
857                self.advance();
858                let name = self.expect_ident_like()?;
859                let with_data = self.parse_optional_with_data(true)?;
860                Ok(Statement::RefreshMaterializedView { name, with_data })
861            }
862            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
863                self.advance();
864                self.parse_update_after_keyword()
865            }
866            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
867                self.advance();
868                self.parse_delete_after_keyword()
869            }
870            // v6.0.4: ALTER INDEX <name> REBUILD [WITH (encoding = ...)].
871            // ALTER is not a reserved keyword in the lexer — handled
872            // as a bare ident here.
873            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("alter") => {
874                self.advance();
875                self.parse_alter_after_keyword()
876            }
877            // v6.1.7: WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>].
878            // WAIT / POSITION / TIMEOUT are bare idents — no lexer
879            // additions needed.
880            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("wait") => {
881                self.advance();
882                self.parse_wait_after_keyword()
883            }
884            // v6.2.0: ANALYZE [<table>]. ANALYZE is a bare ident.
885            // Bare ANALYZE → analyse every user table; ANALYZE
886            // <name> → re-stats one. The argument is an optional
887            // ident (or quoted ident); anything else is a parse
888            // error.
889            // v6.7.3 — `COMPACT COLD SEGMENTS`. No arguments, no
890            // `WHERE` filter (carved out per V6_7_DESIGN.md
891            // STABILITY). Lex order: identifier "compact" → "cold"
892            // → "segments". Anything else after `COMPACT` is a
893            // parse error.
894            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("compact") => {
895                self.advance();
896                let next = self.peek().clone();
897                let cold = match next {
898                    Token::Ident(s) | Token::QuotedIdent(s) => s,
899                    _ => {
900                        return Err(
901                            self.err(format!("expected COLD after COMPACT, got {:?}", self.peek()))
902                        );
903                    }
904                };
905                if !cold.eq_ignore_ascii_case("cold") {
906                    return Err(self.err(format!("expected COLD after COMPACT, got {cold:?}")));
907                }
908                self.advance();
909                let next = self.peek().clone();
910                let segments = match next {
911                    Token::Ident(s) | Token::QuotedIdent(s) => s,
912                    _ => {
913                        return Err(self.err(format!(
914                            "expected SEGMENTS after COMPACT COLD, got {:?}",
915                            self.peek()
916                        )));
917                    }
918                };
919                if !segments.eq_ignore_ascii_case("segments") {
920                    return Err(self.err(format!(
921                        "expected SEGMENTS after COMPACT COLD, got {segments:?}"
922                    )));
923                }
924                self.advance();
925                Ok(Statement::CompactColdSegments)
926            }
927            // v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ MERGE.
928            // Parsed as a case-insensitive identifier since MERGE
929            // isn't a reserved lexer keyword (collides with the
930            // mysqldump `ALGORITHM = MERGE` view clause if it
931            // were); the inner parser drives the rest of the
932            // surface (USING / ON / WHEN [NOT] MATCHED / THEN).
933            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("merge") => {
934                self.advance();
935                self.parse_merge_after_keyword()
936            }
937            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("analyze") => {
938                self.advance();
939                let target = match self.peek() {
940                    Token::Eof | Token::Semicolon => None,
941                    Token::Ident(_) | Token::QuotedIdent(_) => {
942                        Some(self.expect_ident_like()?)
943                    }
944                    other => {
945                        return Err(self.err(format!(
946                            "expected table name or end of statement after ANALYZE, got {other:?}"
947                        )));
948                    }
949                };
950                Ok(Statement::Analyze(target))
951            }
952            // v7.12.1 — `SET <name> [TO|=] <value>`. The
953            // `default_text_search_config` parameter is consumed
954            // by the FTS function dispatcher; other parameter
955            // names are recorded but treated as a no-op so PG
956            // dump output loads.
957            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {
958                self.advance();
959                // PG allows `SET LOCAL` / `SET SESSION` qualifiers
960                // — accept and ignore. MySQL adds `SET GLOBAL` too
961                // (and the alias `SET @@global.name = …` which the
962                // SessionVar path handles).
963                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"))
964                {
965                    self.advance();
966                }
967                // v7.14.0 — MySQL `SET NAMES <charset> [COLLATE
968                // <collation>]` — change the connection client
969                // charset. SPG stores UTF-8 always and orders
970                // bytewise; accept as a no-op.
971                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("names"))
972                {
973                    self.advance();
974                    // Charset ident-or-string.
975                    if matches!(
976                        self.peek(),
977                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
978                    ) {
979                        self.advance();
980                    }
981                    // Optional `COLLATE <name>`.
982                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate"))
983                    {
984                        self.advance();
985                        if matches!(
986                            self.peek(),
987                            Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
988                        ) {
989                            self.advance();
990                        }
991                    }
992                    return Ok(Statement::Empty);
993                }
994                // v7.16.2 — PG `SET [SESSION] AUTHORIZATION
995                // { DEFAULT | '<role>' | <ident> }` (mailrs
996                // round-10 A.1). pg_dump preamble emits the
997                // `DEFAULT` form to reset session authorization;
998                // SPG has no role system so this is a strict
999                // no-op. PG also accepts `RESET SESSION
1000                // AUTHORIZATION` (handled by the RESET parser
1001                // elsewhere). Reference:
1002                // <https://www.postgresql.org/docs/current/sql-set-session-authorization.html>
1003                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("authorization"))
1004                {
1005                    self.advance(); // AUTHORIZATION
1006                    match self.peek().clone() {
1007                        Token::Default => {
1008                            self.advance();
1009                        }
1010                        Token::String(_)
1011                        | Token::Ident(_)
1012                        | Token::QuotedIdent(_) => {
1013                            self.advance();
1014                        }
1015                        other => {
1016                            return Err(self.err(alloc::format!(
1017                                "expected DEFAULT / '<role>' / <ident> after SET SESSION AUTHORIZATION, got {other:?}"
1018                            )));
1019                        }
1020                    }
1021                    return Ok(Statement::Empty);
1022                }
1023                // v7.14.0 — MySQL `SET CHARACTER SET <charset>`
1024                // alias — same accept-as-no-op as SET NAMES.
1025                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
1026                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
1027                {
1028                    self.advance(); // CHARACTER
1029                    self.advance(); // SET
1030                    if matches!(
1031                        self.peek(),
1032                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
1033                    ) {
1034                        self.advance();
1035                    }
1036                    return Ok(Statement::Empty);
1037                }
1038                // v7.14.0 — multi-assignment form
1039                // `SET a = 1, b = 2, …`. Single-assignment is the
1040                // 1-element case. Each LHS may be a regular ident
1041                // or a SessionVar (`@VAR` / `@@VAR`).
1042                let mut pairs: Vec<(String, crate::ast::SetValue)> = Vec::new();
1043                loop {
1044                    let lhs = match self.peek().clone() {
1045                        Token::SessionVar(s) => {
1046                            self.advance();
1047                            s
1048                        }
1049                        Token::Ident(_) | Token::QuotedIdent(_) => self.parse_set_param_name()?,
1050                        other => {
1051                            return Err(self.err(format!(
1052                                "expected parameter name after SET, got {other:?}"
1053                            )));
1054                        }
1055                    };
1056                    // Accept either `=` or the bare `TO` keyword.
1057                    match self.peek() {
1058                        Token::Eq => {
1059                            self.advance();
1060                        }
1061                        Token::To => {
1062                            self.advance();
1063                        }
1064                        other => {
1065                            return Err(self.err(format!(
1066                                "expected `=` or TO after SET {lhs}, got {other:?}"
1067                            )));
1068                        }
1069                    }
1070                    let value = self.parse_set_value()?;
1071                    pairs.push((lhs, value));
1072                    if matches!(self.peek(), Token::Comma) {
1073                        self.advance();
1074                        continue;
1075                    }
1076                    break;
1077                }
1078                if pairs.len() == 1 {
1079                    let (name, value) = pairs.into_iter().next().unwrap();
1080                    Ok(Statement::SetParameter { name, value })
1081                } else {
1082                    Ok(Statement::SetParameterList(pairs))
1083                }
1084            }
1085            // v7.12.1 — `RESET <name>` / `RESET ALL`.
1086            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("reset") => {
1087                self.advance();
1088                match self.peek().clone() {
1089                    Token::All => {
1090                        self.advance();
1091                        Ok(Statement::ResetParameter(None))
1092                    }
1093                    Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("all") => {
1094                        self.advance();
1095                        Ok(Statement::ResetParameter(None))
1096                    }
1097                    _ => {
1098                        let name = self.parse_set_param_name()?;
1099                        Ok(Statement::ResetParameter(Some(name)))
1100                    }
1101                }
1102            }
1103            other => Err(self.err(format!(
1104                "expected SELECT / CREATE / DROP / INSERT / UPDATE / DELETE / ALTER / BEGIN / COMMIT / \
1105                 ROLLBACK / SAVEPOINT / RELEASE / SHOW at start of statement, got {other:?}"
1106            ))),
1107        }
1108    }
1109
1110    fn parse_create_stmt(&mut self) -> Result<Statement, ParseError> {
1111        debug_assert!(matches!(self.peek(), Token::Create));
1112        self.advance();
1113        match self.peek() {
1114            Token::Table => self.parse_create_table_stmt_after_create(),
1115            Token::Index => self.parse_create_index_stmt_after_create(false),
1116            // v7.9.29 — `CREATE UNIQUE INDEX … [WHERE pred]`.
1117            // The `UNIQUE` modifier turns a partial index into a
1118            // partial-uniqueness invariant (only rows matching the
1119            // WHERE predicate are checked for duplicates). mailrs
1120            // K1 (3 hits: email_templates default, calendar_events
1121            // master, calendar_events instance).
1122            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unique") => {
1123                self.advance();
1124                if !matches!(self.peek(), Token::Index) {
1125                    return Err(self.err(alloc::format!(
1126                        "expected INDEX after CREATE UNIQUE, got {:?}",
1127                        self.peek()
1128                    )));
1129                }
1130                self.parse_create_index_stmt_after_create(true)
1131            }
1132            Token::Publication => {
1133                self.advance();
1134                self.parse_create_publication_after_keyword()
1135            }
1136            Token::Subscription => {
1137                self.advance();
1138                self.parse_create_subscription_after_keyword()
1139            }
1140            // v4.1: CREATE USER 'name' WITH PASSWORD 'pw' [ROLE 'role'].
1141            // USER isn't a reserved keyword — we look for the bare
1142            // identifier so the lexer doesn't have to grow a token.
1143            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("user") => {
1144                self.advance();
1145                self.parse_create_user_after_keyword()
1146            }
1147            // v7.9.15 — `CREATE EXTENSION [IF NOT EXISTS] <name>
1148            // [WITH SCHEMA …] [VERSION '…'] [CASCADE]` as a
1149            // no-op. mailrs follow-up F3.
1150            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("extension") => {
1151                self.advance();
1152                self.parse_create_extension_after_keyword()
1153            }
1154            // v7.12.4 — `CREATE [OR REPLACE] FUNCTION …` and
1155            // `CREATE [OR REPLACE] TRIGGER …`. `OR REPLACE` is
1156            // optional; absorb it here and forward to the
1157            // per-kind parsers with the flag. OR is a reserved
1158            // keyword token.
1159            Token::Or => {
1160                self.advance();
1161                let next = self.peek();
1162                let (Token::Ident(s2) | Token::QuotedIdent(s2)) = next else {
1163                    return Err(self.err(alloc::format!(
1164                        "expected REPLACE after CREATE OR, got {next:?}"
1165                    )));
1166                };
1167                if !s2.eq_ignore_ascii_case("replace") {
1168                    return Err(self.err(alloc::format!(
1169                        "expected REPLACE after CREATE OR, got {s2:?}"
1170                    )));
1171                }
1172                self.advance();
1173                self.parse_create_function_or_trigger_after_or_replace(true)
1174            }
1175            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("function") => {
1176                self.advance();
1177                self.parse_create_function_after_keyword(false)
1178            }
1179            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("trigger") => {
1180                self.advance();
1181                self.parse_create_trigger_after_keyword(false)
1182            }
1183            // v7.17.0 — CREATE [TEMPORARY] SEQUENCE …
1184            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
1185                self.advance();
1186                self.parse_create_sequence_after_keyword(false)
1187            }
1188            // v7.17.0 Phase 1.2 — CREATE [TEMPORARY] VIEW …
1189            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("view") => {
1190                self.advance();
1191                self.parse_create_view_after_keyword(false, false, false)
1192            }
1193            // v7.17.0 Phase 2.6 — MySQL view prefix clauses
1194            // `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}` /
1195            // `DEFINER = <user>` / `SQL SECURITY {DEFINER|INVOKER}`
1196            // appear (in any order) between `CREATE` and `VIEW` in
1197            // every mysqldump-emitted view. Pre-2.6 the parser
1198            // rejected the prefix and the customer's whole view
1199            // backup failed on the first view. The hints are pure
1200            // planner / permission metadata; SPG's view-rewrite
1201            // path is semantically equivalent for all three
1202            // algorithms in v7.17 (TEMPTABLE differs only in
1203            // perf for huge views — out of v7.17 scope), and
1204            // DEFINER / SQL SECURITY are pure single-user
1205            // permissioning that SPG ignores by design.
1206            Token::Ident(s) | Token::QuotedIdent(s)
1207                if s.eq_ignore_ascii_case("algorithm")
1208                    || s.eq_ignore_ascii_case("definer")
1209                    || s.eq_ignore_ascii_case("sql") =>
1210            {
1211                self.consume_mysql_view_prefix()?;
1212                // After absorbing ALGORITHM / DEFINER / SQL SECURITY
1213                // (in any order, in any combination), the next
1214                // keyword must be VIEW. mysqldump never emits these
1215                // prefixes on non-view statements.
1216                let next = self.peek().clone();
1217                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2)
1218                    if s2.eq_ignore_ascii_case("view"))
1219                {
1220                    self.advance();
1221                    self.parse_create_view_after_keyword(false, false, false)
1222                } else {
1223                    Err(self.err(alloc::format!(
1224                        "expected VIEW after MySQL view prefix (ALGORITHM/DEFINER/SQL SECURITY), got {next:?}"
1225                    )))
1226                }
1227            }
1228            // v7.17.0 Phase 1.4 — CREATE TYPE name AS ENUM (…).
1229            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("type") => {
1230                self.advance();
1231                self.parse_create_type_after_keyword()
1232            }
1233            // v7.17.0 Phase 1.5 — CREATE DOMAIN name AS base
1234            // [DEFAULT expr] [NOT NULL] [CHECK (expr)]*.
1235            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("domain") => {
1236                self.advance();
1237                self.parse_create_domain_after_keyword()
1238            }
1239            // v7.17.0 Phase 1.6 — CREATE SCHEMA [IF NOT EXISTS]
1240            // name [AUTHORIZATION user]. Real catalog registry
1241            // (was silent-no-op'd pre-v7.17).
1242            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("schema") => {
1243                self.advance();
1244                let if_not_exists = self.parse_if_not_exists();
1245                let name = self.expect_ident_like()?;
1246                // Optional `AUTHORIZATION <user>` trailer — accepted,
1247                // ignored (single-user catalog).
1248                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
1249                    if s.eq_ignore_ascii_case("authorization"))
1250                {
1251                    self.advance();
1252                    let _ = self.expect_ident_like()?;
1253                }
1254                Ok(Statement::CreateSchema { name, if_not_exists })
1255            }
1256            // v7.17.0 Phase 1.3 — CREATE MATERIALIZED VIEW …
1257            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("materialized") => {
1258                self.advance();
1259                let next = self.peek().clone();
1260                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1261                {
1262                    self.advance();
1263                    self.parse_create_materialized_view_after_keyword()
1264                } else {
1265                    Err(self.err(alloc::format!(
1266                        "expected VIEW after CREATE MATERIALIZED, got {next:?}"
1267                    )))
1268                }
1269            }
1270            Token::Ident(s) | Token::QuotedIdent(s)
1271                if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") =>
1272            {
1273                self.advance();
1274                // TEMPORARY/TEMP followed by SEQUENCE / VIEW.
1275                let next = self.peek().clone();
1276                if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("sequence"))
1277                {
1278                    self.advance();
1279                    self.parse_create_sequence_after_keyword(true)
1280                } else if matches!(&next, Token::Ident(s2) | Token::QuotedIdent(s2) if s2.eq_ignore_ascii_case("view"))
1281                {
1282                    self.advance();
1283                    self.parse_create_view_after_keyword(false, false, true)
1284                } else {
1285                    // TEMP TABLE etc — consume to boundary as noop for now.
1286                    self.consume_until_statement_boundary();
1287                    Ok(Statement::Empty)
1288                }
1289            }
1290            // v7.17.0 Phase 4.2 — MySQL `CREATE PROCEDURE name (…)
1291            // BEGIN <body> END`. The body may reference `@var`
1292            // session variables, SET statements, internal `;`
1293            // terminators, etc. SPG has no procedure runtime, so
1294            // consume the whole `CREATE PROCEDURE … END` block as
1295            // a no-op so mysqldump scripts that include stored
1296            // routines load through. The matching-END consumer
1297            // tracks BEGIN/END nesting depth to handle nested
1298            // BEGIN blocks correctly.
1299            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("procedure") => {
1300                self.consume_mysql_routine_body();
1301                Ok(Statement::Empty)
1302            }
1303            // v7.14.0 — pg_dump / mysqldump emit
1304            // `CREATE SCHEMA / VIEW / MATERIALIZED VIEW /
1305            // TYPE / DOMAIN / DATABASE / ROLE / POLICY / OPERATOR`.
1306            // SPG is single-schema / single-database; these have
1307            // no behavioural effect, so consume + return Empty.
1308            // v7.17.0 NOTE: SEQUENCE / VIEW / MATERIALIZED VIEW /
1309            // TYPE / DOMAIN / SCHEMA were here pre-v7.17; all
1310            // moved up to real parser branches. DATABASE / ROLE /
1311            // POLICY / OPERATOR stay no-op forever
1312            // (single-database, hardcoded roles).
1313            Token::Ident(s) | Token::QuotedIdent(s)
1314                if matches!(
1315                    s.to_ascii_lowercase().as_str(),
1316                    "database"
1317                        | "role"
1318                        | "policy"
1319                        | "operator"
1320                        | "cast"
1321                        | "rule"
1322                        | "aggregate"
1323                        | "language"
1324                        | "collation"
1325                        | "conversion"
1326                        // v7.17.0 Phase 8 (audit N6) — rarely-
1327                        // emitted pg_dump shapes that should
1328                        // load through without a parser error.
1329                        // SPG has no planner statistics catalog,
1330                        // no event-trigger hooks, no foreign-
1331                        // data-wrapper infrastructure; consume
1332                        // + return Empty.
1333                        | "statistics"
1334                        | "event"
1335                        | "foreign"
1336                ) =>
1337            {
1338                self.consume_until_statement_boundary();
1339                Ok(Statement::Empty)
1340            }
1341            other => Err(self.err(format!(
1342                "expected TABLE / INDEX / USER / EXTENSION / PUBLICATION / SUBSCRIPTION / FUNCTION / TRIGGER / SEQUENCE / SCHEMA / VIEW / TYPE / DOMAIN [OR REPLACE …] after CREATE, got {other:?}"
1343            ))),
1344        }
1345    }
1346
1347    /// v7.12.4 — `CREATE OR REPLACE` already consumed; the next
1348    /// keyword decides whether we parse a function or trigger
1349    /// body. PG accepts other `OR REPLACE`-able objects (VIEW,
1350    /// PROCEDURE) — those land in later releases.
1351    fn parse_create_function_or_trigger_after_or_replace(
1352        &mut self,
1353        or_replace: bool,
1354    ) -> Result<Statement, ParseError> {
1355        let tok = self.peek();
1356        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1357            return Err(self.err(alloc::format!(
1358                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {tok:?}"
1359            )));
1360        };
1361        if s.eq_ignore_ascii_case("function") {
1362            self.advance();
1363            self.parse_create_function_after_keyword(or_replace)
1364        } else if s.eq_ignore_ascii_case("trigger") {
1365            self.advance();
1366            self.parse_create_trigger_after_keyword(or_replace)
1367        } else if s.eq_ignore_ascii_case("view") {
1368            // v7.17.0 Phase 1.2 — CREATE OR REPLACE VIEW name AS SELECT …
1369            self.advance();
1370            self.parse_create_view_after_keyword(or_replace, false, false)
1371        } else if s.eq_ignore_ascii_case("temporary") || s.eq_ignore_ascii_case("temp") {
1372            // CREATE OR REPLACE TEMPORARY VIEW … (rare but legal).
1373            self.advance();
1374            let nxt = self.peek().clone();
1375            if matches!(&nxt, Token::Ident(n) | Token::QuotedIdent(n) if n.eq_ignore_ascii_case("view"))
1376            {
1377                self.advance();
1378                self.parse_create_view_after_keyword(or_replace, false, true)
1379            } else {
1380                Err(self.err(alloc::format!(
1381                    "expected VIEW after CREATE OR REPLACE TEMPORARY, got {nxt:?}"
1382                )))
1383            }
1384        } else {
1385            Err(self.err(alloc::format!(
1386                "expected FUNCTION / TRIGGER / VIEW after CREATE OR REPLACE, got {s:?}"
1387            )))
1388        }
1389    }
1390
1391    /// v7.9.15 — accept and discard `CREATE EXTENSION` DDL.
1392    /// SPG doesn't have a registry; pgvector / similar are
1393    /// either builtin (VECTOR(N) ↔ pgvector) or n/a. Parsing
1394    /// the syntax lets dual-target schemas keep the line.
1395    fn parse_create_extension_after_keyword(&mut self) -> Result<Statement, ParseError> {
1396        // Optional `IF NOT EXISTS`.
1397        self.consume_if_not_exists();
1398        let name = self.expect_ident_like()?;
1399        // Drain optional WITH SCHEMA <ident> / VERSION '<v>' /
1400        // CASCADE / FROM '<v>' clauses; we don't model them.
1401        loop {
1402            match self.peek() {
1403                Token::Ident(s) if s.eq_ignore_ascii_case("with") => {
1404                    self.advance();
1405                    continue;
1406                }
1407                Token::Ident(s) if s.eq_ignore_ascii_case("schema") => {
1408                    self.advance();
1409                    let _ = self.expect_ident_like()?;
1410                    continue;
1411                }
1412                Token::Ident(s) if s.eq_ignore_ascii_case("version") => {
1413                    self.advance();
1414                    // String or ident literal.
1415                    let _ = self.advance();
1416                    continue;
1417                }
1418                Token::Ident(s) if s.eq_ignore_ascii_case("from") => {
1419                    self.advance();
1420                    let _ = self.advance();
1421                    continue;
1422                }
1423                Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => {
1424                    self.advance();
1425                    continue;
1426                }
1427                _ => break,
1428            }
1429        }
1430        Ok(Statement::CreateExtension(name))
1431    }
1432
1433    /// v7.12.4 — body of `CREATE [OR REPLACE] FUNCTION`. The
1434    /// `[OR REPLACE]` flag (and the `FUNCTION` keyword) have
1435    /// already been consumed by the caller. Grammar accepted:
1436    ///
1437    ///   name `(` arg-list `)`
1438    ///   `RETURNS` return-type
1439    ///   [ `LANGUAGE` ident ]
1440    ///   `AS` $$ body $$
1441    ///   [ `LANGUAGE` ident ]
1442    ///
1443    /// Either `LANGUAGE` position is allowed; PG accepts both.
1444    fn parse_create_function_after_keyword(
1445        &mut self,
1446        or_replace: bool,
1447    ) -> Result<Statement, ParseError> {
1448        let name = self.expect_ident_like()?;
1449        // Argument list. v7.12.4 commonly sees the empty `()`
1450        // (trigger functions); typed args parse and round-trip
1451        // but the executor only invokes nullary functions.
1452        if !matches!(self.peek(), Token::LParen) {
1453            return Err(self.err(alloc::format!(
1454                "expected '(' after function name {name:?}, got {:?}",
1455                self.peek()
1456            )));
1457        }
1458        self.advance();
1459        let args = self.parse_function_arg_list()?;
1460        // RETURNS clause.
1461        let tok = self.peek();
1462        let (Token::Ident(s) | Token::QuotedIdent(s)) = tok else {
1463            return Err(self.err(alloc::format!(
1464                "expected RETURNS after function arg list, got {tok:?}"
1465            )));
1466        };
1467        if !s.eq_ignore_ascii_case("returns") {
1468            return Err(self.err(alloc::format!(
1469                "expected RETURNS after function arg list, got {s:?}"
1470            )));
1471        }
1472        self.advance();
1473        let returns = self.parse_function_return()?;
1474        // Optional LANGUAGE clause (PG also accepts after AS — we'll
1475        // re-check after the body too).
1476        let mut language: Option<String> = self.parse_optional_language()?;
1477        // `AS` followed by a $$-quoted body (lexer already
1478        // collapses both `$$…$$` and `$tag$…$tag$` to a single
1479        // Token::String). AS is a reserved keyword (Token::As).
1480        if !matches!(self.peek(), Token::As) {
1481            return Err(self.err(alloc::format!(
1482                "expected AS before function body, got {:?}",
1483                self.peek()
1484            )));
1485        }
1486        self.advance();
1487        let body_text = match self.peek() {
1488            Token::String(s) => {
1489                let body = s.clone();
1490                self.advance();
1491                body
1492            }
1493            other => {
1494                return Err(self.err(alloc::format!(
1495                    "expected $$-quoted function body after AS, got {other:?}"
1496                )));
1497            }
1498        };
1499        // Trailing optional LANGUAGE clause (the other PG position).
1500        if language.is_none() {
1501            language = self.parse_optional_language()?;
1502        }
1503        let language = language.unwrap_or_else(|| String::from("sql"));
1504        // PL/pgSQL bodies get structure-parsed. Other languages
1505        // (or PL/pgSQL bodies the v7.12.4 parser doesn't yet
1506        // recognise) round-trip as Raw text — the executor errors
1507        // when invoked with a clear unsupported message.
1508        let body = if language.eq_ignore_ascii_case("plpgsql") {
1509            match parse_plpgsql_body(&body_text) {
1510                Ok(block) => FunctionBody::PlPgSql(block),
1511                // Best-effort: if the body parser doesn't yet
1512                // support a construct used inside, fall back to
1513                // raw — keeps `CREATE FUNCTION` itself working
1514                // (catalogue accepts), executor errors on
1515                // invocation only.
1516                Err(_) => FunctionBody::Raw(body_text),
1517            }
1518        } else {
1519            FunctionBody::Raw(body_text)
1520        };
1521        Ok(Statement::CreateFunction(CreateFunctionStatement {
1522            name,
1523            or_replace,
1524            args,
1525            returns,
1526            language,
1527            body,
1528        }))
1529    }
1530
1531    /// Closing `)`-terminated argument list. v7.12.4 commonly
1532    /// sees the empty `()`; typed args round-trip but the
1533    /// executor (yet) doesn't invoke them.
1534    fn parse_function_arg_list(&mut self) -> Result<Vec<FunctionArg>, ParseError> {
1535        let mut args: Vec<FunctionArg> = Vec::new();
1536        if matches!(self.peek(), Token::RParen) {
1537            self.advance();
1538            return Ok(args);
1539        }
1540        loop {
1541            // Optional `IN` / `OUT` / `INOUT` mode keyword. IN is
1542            // a reserved token; OUT / INOUT are bare idents.
1543            let mode = if matches!(self.peek(), Token::In) {
1544                self.advance();
1545                FunctionArgMode::In
1546            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("out"))
1547            {
1548                self.advance();
1549                FunctionArgMode::Out
1550            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("inout"))
1551            {
1552                self.advance();
1553                FunctionArgMode::InOut
1554            } else {
1555                FunctionArgMode::In
1556            };
1557            // Optional name. The next token is either a name
1558            // (followed by a type ident) or the type itself.
1559            // Disambiguate by peeking ahead: if the token after
1560            // the next ident is also an ident, we treat the
1561            // first as the name.
1562            let (name, ty_token) = {
1563                let first = self.expect_ident_like()?;
1564                // Peek next: if it's an ident (i.e. a type
1565                // name) the `first` was the arg name.
1566                match self.peek() {
1567                    Token::Ident(_) | Token::QuotedIdent(_) => {
1568                        let ty = self.expect_ident_like()?;
1569                        (Some(first), ty)
1570                    }
1571                    _ => (None, first),
1572                }
1573            };
1574            // Type — try to map to ColumnTypeName, else Raw.
1575            let ty = match map_type_ident_to_column_type_name(&ty_token) {
1576                Some(t) => FunctionArgType::Typed(t),
1577                None => FunctionArgType::Raw(ty_token),
1578            };
1579            args.push(FunctionArg { mode, name, ty });
1580            match self.peek() {
1581                Token::Comma => {
1582                    self.advance();
1583                    continue;
1584                }
1585                Token::RParen => {
1586                    self.advance();
1587                    return Ok(args);
1588                }
1589                other => {
1590                    return Err(self.err(alloc::format!(
1591                        "expected , or ) in function arg list, got {other:?}"
1592                    )));
1593                }
1594            }
1595        }
1596    }
1597
1598    fn parse_function_return(&mut self) -> Result<FunctionReturn, ParseError> {
1599        let ident = self.expect_ident_like()?;
1600        if ident.eq_ignore_ascii_case("trigger") {
1601            return Ok(FunctionReturn::Trigger);
1602        }
1603        if ident.eq_ignore_ascii_case("void") {
1604            return Ok(FunctionReturn::Void);
1605        }
1606        match map_type_ident_to_column_type_name(&ident) {
1607            Some(t) => Ok(FunctionReturn::Type(t)),
1608            None => Ok(FunctionReturn::Other(ident)),
1609        }
1610    }
1611
1612    fn parse_optional_language(&mut self) -> Result<Option<String>, ParseError> {
1613        match self.peek() {
1614            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("language") => {
1615                self.advance();
1616                let lang = self.expect_ident_like()?;
1617                Ok(Some(lang.to_ascii_lowercase()))
1618            }
1619            _ => Ok(None),
1620        }
1621    }
1622
1623    /// v7.17.0 Phase 1.5 — body of `CREATE DOMAIN name AS
1624    /// base_type [DEFAULT expr] [NOT NULL | NULL] [CHECK
1625    /// (expr)]*`. The `DOMAIN` keyword has already been
1626    /// consumed. PG allows the trailing constraints in any
1627    /// order; we approximate with a small loop.
1628    fn parse_create_domain_after_keyword(&mut self) -> Result<Statement, ParseError> {
1629        let name = self.expect_ident_like()?;
1630        // Optional `AS`.
1631        if matches!(self.peek(), Token::As) {
1632            self.advance();
1633        }
1634        let base_type = self.parse_column_type_name()?;
1635        let mut default: Option<Expr> = None;
1636        let mut not_null = false;
1637        let mut checks: Vec<Expr> = Vec::new();
1638        loop {
1639            match self.peek() {
1640                Token::Default => {
1641                    if default.is_some() {
1642                        return Err(self.err("DOMAIN DEFAULT specified twice".into()));
1643                    }
1644                    self.advance();
1645                    default = Some(self.parse_expr(0)?);
1646                }
1647                Token::Not => {
1648                    self.advance();
1649                    if !matches!(self.peek(), Token::Null) {
1650                        return Err(self.err(alloc::format!(
1651                            "expected NULL after NOT in DOMAIN, got {:?}",
1652                            self.peek()
1653                        )));
1654                    }
1655                    self.advance();
1656                    not_null = true;
1657                }
1658                Token::Null => {
1659                    self.advance();
1660                    // NULL after a NOT NULL is contradictory, but
1661                    // PG accepts bare NULL as the default-nullable
1662                    // marker. No-op.
1663                }
1664                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("check") => {
1665                    self.advance();
1666                    if !matches!(self.peek(), Token::LParen) {
1667                        return Err(self.err(alloc::format!(
1668                            "expected '(' after CHECK in DOMAIN, got {:?}",
1669                            self.peek()
1670                        )));
1671                    }
1672                    self.advance();
1673                    let expr = self.parse_expr(0)?;
1674                    if !matches!(self.peek(), Token::RParen) {
1675                        return Err(self.err(alloc::format!(
1676                            "expected ')' after CHECK expr, got {:?}",
1677                            self.peek()
1678                        )));
1679                    }
1680                    self.advance();
1681                    checks.push(expr);
1682                }
1683                // CONSTRAINT <name> CHECK (…) — PG accepts a name
1684                // prefix on the constraint; we drop the name and
1685                // recurse into the constraint parsing.
1686                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("constraint") => {
1687                    self.advance();
1688                    let _ = self.expect_ident_like()?;
1689                }
1690                _ => break,
1691            }
1692        }
1693        Ok(Statement::CreateDomain(crate::ast::CreateDomainStatement {
1694            name,
1695            base_type,
1696            default,
1697            not_null,
1698            checks,
1699        }))
1700    }
1701
1702    /// v7.17.0 Phase 1.4 — body of `CREATE TYPE name AS ENUM
1703    /// ('a', 'b', …)`. The `TYPE` keyword has already been
1704    /// consumed.
1705    fn parse_create_type_after_keyword(&mut self) -> Result<Statement, ParseError> {
1706        let name = self.expect_ident_like()?;
1707        // Required `AS`.
1708        if !matches!(self.peek(), Token::As) {
1709            return Err(self.err(alloc::format!(
1710                "expected AS after CREATE TYPE {name:?}, got {:?}",
1711                self.peek()
1712            )));
1713        }
1714        self.advance();
1715        // Required `ENUM` ident.
1716        let kind_ident = match self.peek().clone() {
1717            Token::Ident(s) | Token::QuotedIdent(s) => s,
1718            other => {
1719                return Err(self.err(alloc::format!(
1720                    "expected ENUM after CREATE TYPE {name:?} AS, got {other:?}"
1721                )));
1722            }
1723        };
1724        if !kind_ident.eq_ignore_ascii_case("enum") {
1725            return Err(self.err(alloc::format!(
1726                "Phase 1.4 only supports ENUM; got {kind_ident:?}"
1727            )));
1728        }
1729        self.advance();
1730        if !matches!(self.peek(), Token::LParen) {
1731            return Err(self.err(alloc::format!(
1732                "expected '(' after ENUM, got {:?}",
1733                self.peek()
1734            )));
1735        }
1736        self.advance();
1737        let mut labels: Vec<String> = Vec::new();
1738        loop {
1739            match self.peek().clone() {
1740                Token::String(s) => {
1741                    self.advance();
1742                    labels.push(s);
1743                }
1744                other => {
1745                    return Err(
1746                        self.err(alloc::format!("expected enum label string, got {other:?}"))
1747                    );
1748                }
1749            }
1750            if matches!(self.peek(), Token::Comma) {
1751                self.advance();
1752                continue;
1753            }
1754            if matches!(self.peek(), Token::RParen) {
1755                self.advance();
1756                break;
1757            }
1758            return Err(self.err(alloc::format!(
1759                "expected , or ) in ENUM label list, got {:?}",
1760                self.peek()
1761            )));
1762        }
1763        if labels.is_empty() {
1764            return Err(self.err("CREATE TYPE … AS ENUM must declare at least one label".into()));
1765        }
1766        Ok(Statement::CreateType(crate::ast::CreateTypeStatement {
1767            name,
1768            kind: crate::ast::TypeKind::Enum { labels },
1769        }))
1770    }
1771
1772    /// v7.17.0 Phase 1.3 — body of `CREATE MATERIALIZED VIEW
1773    /// [IF NOT EXISTS] name [(col, …)] AS <SELECT …> [WITH [NO] DATA]`.
1774    /// The `CREATE MATERIALIZED VIEW` keywords have already been
1775    /// consumed.
1776    fn parse_create_materialized_view_after_keyword(&mut self) -> Result<Statement, ParseError> {
1777        let if_not_exists = self.parse_if_not_exists();
1778        let name = self.expect_ident_like()?;
1779        let mut columns: Vec<String> = Vec::new();
1780        if matches!(self.peek(), Token::LParen) {
1781            self.advance();
1782            loop {
1783                let c = self.expect_ident_like()?;
1784                columns.push(c);
1785                if matches!(self.peek(), Token::Comma) {
1786                    self.advance();
1787                    continue;
1788                }
1789                if matches!(self.peek(), Token::RParen) {
1790                    self.advance();
1791                    break;
1792                }
1793                return Err(self.err(alloc::format!(
1794                    "expected , or ) in MATERIALIZED VIEW column list, got {:?}",
1795                    self.peek()
1796                )));
1797            }
1798        }
1799        if !matches!(self.peek(), Token::As) {
1800            return Err(self.err(alloc::format!(
1801                "expected AS <SELECT …> after CREATE MATERIALIZED VIEW {name:?}, got {:?}",
1802                self.peek()
1803            )));
1804        }
1805        self.advance();
1806        let body_stmt = self.parse_select_stmt()?;
1807        let Statement::Select(body) = body_stmt else {
1808            return Err(self.err(alloc::format!(
1809                "CREATE MATERIALIZED VIEW body must be a SELECT, got {body_stmt:?}"
1810            )));
1811        };
1812        // Optional trailing `WITH [NO] DATA`.
1813        let with_data = self.parse_optional_with_data(true)?;
1814        Ok(Statement::CreateMaterializedView(
1815            crate::ast::CreateMaterializedViewStatement {
1816                name,
1817                if_not_exists,
1818                columns,
1819                body,
1820                with_data,
1821            },
1822        ))
1823    }
1824
1825    /// v7.17.0 Phase 1.3 — `WITH [NO] DATA` trailer.
1826    /// `default_when_absent` is what to return if the tail is
1827    /// missing (CREATE defaults to WITH DATA, REFRESH defaults to
1828    /// WITH DATA).
1829    fn parse_optional_with_data(&mut self, default_when_absent: bool) -> Result<bool, ParseError> {
1830        let save = self.pos;
1831        // `WITH` is an Ident (not reserved in the lexer).
1832        let is_with = match self.peek() {
1833            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("with"),
1834            _ => false,
1835        };
1836        if !is_with {
1837            return Ok(default_when_absent);
1838        }
1839        self.advance();
1840        // Optional `NO`.
1841        let mut with_data = true;
1842        let is_no = match self.peek() {
1843            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("no"),
1844            _ => false,
1845        };
1846        if is_no {
1847            self.advance();
1848            with_data = false;
1849        }
1850        // Required `DATA` ident.
1851        let is_data = match self.peek() {
1852            Token::Ident(s) | Token::QuotedIdent(s) => s.eq_ignore_ascii_case("data"),
1853            _ => false,
1854        };
1855        if is_data {
1856            self.advance();
1857            Ok(with_data)
1858        } else {
1859            // Caller's WITH wasn't WITH-DATA — rewind so the outer
1860            // parser can interpret it.
1861            self.pos = save;
1862            Ok(default_when_absent)
1863        }
1864    }
1865
1866    /// v7.17.0 Phase 1.2 — body of `CREATE [OR REPLACE]
1867    /// [TEMPORARY] VIEW [IF NOT EXISTS] name [(col, …)] AS <SELECT>`.
1868    /// All keyword prefixes have already been consumed; the flags
1869    /// say which were present.
1870    fn parse_create_view_after_keyword(
1871        &mut self,
1872        or_replace: bool,
1873        _materialized_unused: bool,
1874        temporary: bool,
1875    ) -> Result<Statement, ParseError> {
1876        let if_not_exists = self.parse_if_not_exists();
1877        let name = self.expect_ident_like()?;
1878        // Optional `(col, col, …)` rename list.
1879        let mut columns: Vec<String> = Vec::new();
1880        if matches!(self.peek(), Token::LParen) {
1881            self.advance();
1882            loop {
1883                let c = self.expect_ident_like()?;
1884                columns.push(c);
1885                if matches!(self.peek(), Token::Comma) {
1886                    self.advance();
1887                    continue;
1888                }
1889                if matches!(self.peek(), Token::RParen) {
1890                    self.advance();
1891                    break;
1892                }
1893                return Err(self.err(alloc::format!(
1894                    "expected , or ) in VIEW column list, got {:?}",
1895                    self.peek()
1896                )));
1897            }
1898        }
1899        // Required `AS`.
1900        if !matches!(self.peek(), Token::As) {
1901            return Err(self.err(alloc::format!(
1902                "expected AS <SELECT …> after CREATE VIEW {name:?}, got {:?}",
1903                self.peek()
1904            )));
1905        }
1906        self.advance();
1907        // Body: a regular SELECT statement.
1908        let body_stmt = self.parse_select_stmt()?;
1909        let Statement::Select(body) = body_stmt else {
1910            return Err(self.err(alloc::format!(
1911                "CREATE VIEW body must be a SELECT statement, got {body_stmt:?}"
1912            )));
1913        };
1914        Ok(Statement::CreateView(crate::ast::CreateViewStatement {
1915            name,
1916            or_replace,
1917            if_not_exists,
1918            temporary,
1919            columns,
1920            body,
1921        }))
1922    }
1923
1924    /// v7.17.0 — body of `CREATE [TEMPORARY] SEQUENCE`. The
1925    /// `[TEMPORARY]` and `SEQUENCE` tokens have already been
1926    /// consumed; `temporary` carries whether TEMPORARY was seen.
1927    fn parse_create_sequence_after_keyword(
1928        &mut self,
1929        temporary: bool,
1930    ) -> Result<Statement, ParseError> {
1931        let if_not_exists = self.parse_if_not_exists();
1932        let name = self.expect_ident_like()?;
1933        // Optional `AS data_type`.
1934        let data_type = if matches!(self.peek(), Token::As) {
1935            self.advance();
1936            Some(self.parse_sequence_data_type()?)
1937        } else {
1938            None
1939        };
1940        let options = self.parse_sequence_options(/* allow_restart = */ false)?;
1941        Ok(Statement::CreateSequence(
1942            crate::ast::CreateSequenceStatement {
1943                name,
1944                if_not_exists,
1945                temporary,
1946                data_type,
1947                options,
1948            },
1949        ))
1950    }
1951
1952    /// v7.17.0 — body of `ALTER SEQUENCE`. The `ALTER` keyword has
1953    /// already been consumed; this is reached after `SEQUENCE`.
1954    fn parse_alter_sequence_after_keyword(&mut self) -> Result<Statement, ParseError> {
1955        let if_exists = self.parse_if_exists();
1956        let name = self.expect_ident_like()?;
1957        let options = self.parse_sequence_options(/* allow_restart = */ true)?;
1958        Ok(Statement::AlterSequence(
1959            crate::ast::AlterSequenceStatement {
1960                name,
1961                if_exists,
1962                options,
1963            },
1964        ))
1965    }
1966
1967    fn parse_sequence_data_type(&mut self) -> Result<crate::ast::SequenceDataType, ParseError> {
1968        let kw = self.expect_ident_like()?;
1969        match kw.to_ascii_lowercase().as_str() {
1970            "smallint" | "int2" => Ok(crate::ast::SequenceDataType::SmallInt),
1971            "integer" | "int" | "int4" => Ok(crate::ast::SequenceDataType::Int),
1972            "bigint" | "int8" => Ok(crate::ast::SequenceDataType::BigInt),
1973            other => Err(self.err(alloc::format!(
1974                "expected SMALLINT / INTEGER / BIGINT after SEQUENCE AS, got {other:?}"
1975            ))),
1976        }
1977    }
1978
1979    fn parse_sequence_options(
1980        &mut self,
1981        allow_restart: bool,
1982    ) -> Result<crate::ast::SequenceOptions, ParseError> {
1983        use crate::ast::{SeqBound, SequenceOptions, SequenceOwnedBy};
1984        let mut opts = SequenceOptions::default();
1985        #[allow(clippy::while_let_loop)]
1986        loop {
1987            // Match an ident; stop at any non-ident token (sentinel,
1988            // semicolon, end of statement).
1989            let kw_lc = match self.peek() {
1990                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
1991                _ => break,
1992            };
1993            match kw_lc.as_str() {
1994                "increment" => {
1995                    self.advance();
1996                    // Optional BY.
1997                    if matches!(self.peek(), Token::By) {
1998                        self.advance();
1999                    }
2000                    opts.increment = Some(self.expect_signed_int()?);
2001                }
2002                "minvalue" => {
2003                    self.advance();
2004                    opts.min_value = Some(SeqBound::Value(self.expect_signed_int()?));
2005                }
2006                "maxvalue" => {
2007                    self.advance();
2008                    opts.max_value = Some(SeqBound::Value(self.expect_signed_int()?));
2009                }
2010                "no" => {
2011                    self.advance();
2012                    let what = self.expect_ident_like()?;
2013                    match what.to_ascii_lowercase().as_str() {
2014                        "minvalue" => opts.min_value = Some(SeqBound::NoBound),
2015                        "maxvalue" => opts.max_value = Some(SeqBound::NoBound),
2016                        "cycle" => opts.cycle = Some(false),
2017                        other => {
2018                            return Err(self.err(alloc::format!(
2019                                "expected MINVALUE / MAXVALUE / CYCLE after NO, got {other:?}"
2020                            )));
2021                        }
2022                    }
2023                }
2024                "start" => {
2025                    self.advance();
2026                    // Optional WITH.
2027                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2028                        if s.eq_ignore_ascii_case("with"))
2029                    {
2030                        self.advance();
2031                    }
2032                    opts.start = Some(self.expect_signed_int()?);
2033                }
2034                "restart" if allow_restart => {
2035                    self.advance();
2036                    // Optional WITH n; bare RESTART means restart at START.
2037                    let mut with_val: Option<i64> = None;
2038                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2039                        if s.eq_ignore_ascii_case("with"))
2040                    {
2041                        self.advance();
2042                        with_val = Some(self.expect_signed_int()?);
2043                    } else if matches!(self.peek(), Token::Integer(_) | Token::Minus) {
2044                        with_val = Some(self.expect_signed_int()?);
2045                    }
2046                    opts.restart = Some(with_val);
2047                }
2048                "cache" => {
2049                    self.advance();
2050                    opts.cache = Some(self.expect_signed_int()?);
2051                }
2052                "cycle" => {
2053                    self.advance();
2054                    opts.cycle = Some(true);
2055                }
2056                "owned" => {
2057                    self.advance();
2058                    // BY is a reserved Token::By; accept either form.
2059                    match self.peek() {
2060                        Token::By => {
2061                            self.advance();
2062                        }
2063                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("by") => {
2064                            self.advance();
2065                        }
2066                        other => {
2067                            return Err(
2068                                self.err(alloc::format!("expected BY after OWNED, got {other:?}"))
2069                            );
2070                        }
2071                    }
2072                    // OWNED BY {NONE | tab.col}. Read just one ident
2073                    // (NOT expect_ident_like which would auto-strip
2074                    // a schema prefix and consume the `.col` we need).
2075                    let first = match self.advance() {
2076                        Token::Ident(s) | Token::QuotedIdent(s) => s,
2077                        other => {
2078                            return Err(self.err(alloc::format!(
2079                                "expected identifier or NONE after OWNED BY, got {other:?}"
2080                            )));
2081                        }
2082                    };
2083                    if first.eq_ignore_ascii_case("none") {
2084                        opts.owned_by = Some(SequenceOwnedBy::None);
2085                    } else if matches!(self.peek(), Token::Dot) {
2086                        self.advance();
2087                        let second = match self.advance() {
2088                            Token::Ident(s) | Token::QuotedIdent(s) => s,
2089                            other => {
2090                                return Err(self.err(alloc::format!(
2091                                    "expected column name after OWNED BY {first}., got {other:?}"
2092                                )));
2093                            }
2094                        };
2095                        // v7.17 dump-compat fix — pg_dump emits
2096                        // OWNED BY clauses as
2097                        // `schema.table.column` (three segments).
2098                        // If a third `.<ident>` follows, treat the
2099                        // first ident as schema (drop it; SPG is
2100                        // single-schema) and the middle / last
2101                        // pair as table.column. Otherwise it's
2102                        // the two-segment form table.column.
2103                        if matches!(self.peek(), Token::Dot) {
2104                            self.advance();
2105                            let third = match self.advance() {
2106                                Token::Ident(s) | Token::QuotedIdent(s) => s,
2107                                other => {
2108                                    return Err(self.err(alloc::format!(
2109                                        "expected column name after OWNED BY {first}.{second}., got {other:?}"
2110                                    )));
2111                                }
2112                            };
2113                            let _ = first; // schema prefix discarded
2114                            opts.owned_by = Some(SequenceOwnedBy::Column {
2115                                table: second,
2116                                column: third,
2117                            });
2118                        } else {
2119                            opts.owned_by = Some(SequenceOwnedBy::Column {
2120                                table: first,
2121                                column: second,
2122                            });
2123                        }
2124                    } else {
2125                        return Err(self.err(alloc::format!(
2126                            "expected table.column or NONE after OWNED BY, got {first:?}"
2127                        )));
2128                    }
2129                }
2130                _ => break,
2131            }
2132        }
2133        Ok(opts)
2134    }
2135
2136    fn expect_signed_int(&mut self) -> Result<i64, ParseError> {
2137        let neg = if matches!(self.peek(), Token::Minus) {
2138            self.advance();
2139            true
2140        } else {
2141            false
2142        };
2143        match self.peek() {
2144            Token::Integer(n) => {
2145                let v = *n;
2146                self.advance();
2147                Ok(if neg { -v } else { v })
2148            }
2149            other => Err(self.err(alloc::format!("expected signed integer, got {other:?}"))),
2150        }
2151    }
2152
2153    /// v7.17.0 Phase 3.1 — absorb `[NOT] DEFERRABLE [INITIALLY
2154    /// {DEFERRED | IMMEDIATE}]` constraint-timing clauses. Each
2155    /// clause is fully accepted and discarded — SPG always runs
2156    /// constraint checks immediately (single-writer model). The
2157    /// loop allows DEFERRABLE and the INITIALLY suffix to appear
2158    /// in either order (per the SQL spec they're independent),
2159    /// though pg_dump always emits them in the canonical
2160    /// `[NOT] DEFERRABLE INITIALLY {DEFERRED|IMMEDIATE}` shape.
2161    /// Stops at the first token that isn't part of the clause.
2162    fn consume_optional_deferrable_clauses(&mut self) -> Result<(), ParseError> {
2163        loop {
2164            // Bare `DEFERRABLE` (Phase 3.1 — was hard-error pre-3.1).
2165            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("deferrable")) {
2166                self.advance();
2167                self.consume_optional_initially_clause()?;
2168                continue;
2169            }
2170            // `NOT DEFERRABLE` — already worked pre-3.1.
2171            if matches!(self.peek(), Token::Not) {
2172                let look = self.tokens.get(self.pos + 1);
2173                if matches!(look, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("deferrable")) {
2174                    self.advance(); // NOT
2175                    self.advance(); // DEFERRABLE
2176                    self.consume_optional_initially_clause()?;
2177                    continue;
2178                }
2179                break;
2180            }
2181            // Standalone `INITIALLY {DEFERRED|IMMEDIATE}` — PG
2182            // accepts this without a leading [NOT] DEFERRABLE
2183            // (the timing keyword alone). pg_dump occasionally
2184            // emits it on FK constraints that inherit timing.
2185            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2186                self.consume_optional_initially_clause()?;
2187                continue;
2188            }
2189            break;
2190        }
2191        Ok(())
2192    }
2193
2194    /// Helper for [`consume_optional_deferrable_clauses`]. When the
2195    /// next token is `INITIALLY`, consume it plus the required
2196    /// `DEFERRED` | `IMMEDIATE` trailer. No-op otherwise.
2197    fn consume_optional_initially_clause(&mut self) -> Result<(), ParseError> {
2198        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("initially")) {
2199            return Ok(());
2200        }
2201        self.advance(); // INITIALLY
2202        match self.advance() {
2203            Token::Ident(s)
2204                if s.eq_ignore_ascii_case("deferred") || s.eq_ignore_ascii_case("immediate") =>
2205            {
2206                Ok(())
2207            }
2208            other => Err(self.err(alloc::format!(
2209                "expected DEFERRED or IMMEDIATE after INITIALLY, got {other:?}"
2210            ))),
2211        }
2212    }
2213
2214    /// v7.17.0 Phase 4.2 — consume a MySQL `CREATE PROCEDURE` body
2215    /// in its entirety so the parser returns Empty without
2216    /// touching the runtime. The CREATE+PROCEDURE keywords are
2217    /// already consumed; this swallows everything from the
2218    /// procedure name through the matching `END`, including
2219    /// nested `BEGIN`/`END` blocks, internal `;` terminators
2220    /// (DELIMITER `//` makes the script splitter forward the
2221    /// whole block as one statement), `@var` session-variable
2222    /// references, and the trailing terminator.
2223    ///
2224    /// Tracks nesting depth so:
2225    ///   BEGIN
2226    ///     IF cond THEN
2227    ///       BEGIN ... END;
2228    ///     END IF;
2229    ///   END
2230    /// terminates at the outer END.
2231    fn consume_mysql_routine_body(&mut self) {
2232        // Outer skeleton: name, (...), optional clauses, BEGIN
2233        // <body> END [;]. Scan for the first BEGIN — anything
2234        // before it is signature decoration we don't care about.
2235        // Once inside BEGIN, count up on BEGIN, down on END.
2236        let mut depth: i32 = 0;
2237        let mut started = false;
2238        loop {
2239            match self.peek().clone() {
2240                Token::Begin => {
2241                    self.advance();
2242                    depth += 1;
2243                    started = true;
2244                }
2245                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2246                    self.advance();
2247                    if started {
2248                        depth -= 1;
2249                        if depth <= 0 {
2250                            // Optional trailing ident (`END IF`,
2251                            // `END LOOP`, `END WHILE`, `END CASE`,
2252                            // `END label_name`) — eat the next
2253                            // ident if present so we don't
2254                            // mistake `END IF;` for the outer
2255                            // close.
2256                            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2257                                // If the next token is one of the
2258                                // PL/SQL block-closer keywords,
2259                                // the END belongs to an inner
2260                                // block; bump depth back up.
2261                                let is_inner_close = matches!(
2262                                    self.peek(),
2263                                    Token::Ident(s) | Token::QuotedIdent(s)
2264                                        if matches!(
2265                                            s.to_ascii_lowercase().as_str(),
2266                                            "if" | "loop" | "while" | "case" | "repeat"
2267                                        )
2268                                );
2269                                if is_inner_close {
2270                                    self.advance();
2271                                    depth += 1;
2272                                    continue;
2273                                }
2274                            }
2275                            // Eat optional trailing `;`.
2276                            if matches!(self.peek(), Token::Semicolon) {
2277                                self.advance();
2278                            }
2279                            return;
2280                        }
2281                    }
2282                }
2283                Token::Eof => return,
2284                _ => {
2285                    self.advance();
2286                }
2287            }
2288        }
2289    }
2290
2291    /// v7.17.0 Phase 2.6 — absorb the MySQL view-prefix clauses
2292    /// that appear between `CREATE` and `VIEW` in mysqldump output:
2293    ///
2294    /// * `ALGORITHM = {UNDEFINED|MERGE|TEMPTABLE}`
2295    /// * `DEFINER = <user>`  (user may be a quoted string, a bare
2296    ///   ident, or `ident @ ident-or-quoted-string` host form)
2297    /// * `SQL SECURITY {DEFINER|INVOKER}`
2298    ///
2299    /// Each clause may appear at most once but in any order.
2300    /// The hints are pure planner / permission metadata that
2301    /// SPG's view-rewrite engine handles uniformly; we accept
2302    /// and discard. Returns `Ok(())` once a non-clause token is
2303    /// peeked (the caller then checks for the `VIEW` keyword).
2304    fn consume_mysql_view_prefix(&mut self) -> Result<(), ParseError> {
2305        loop {
2306            match self.peek().clone() {
2307                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("algorithm") => {
2308                    self.advance(); // ALGORITHM
2309                    // Optional `=`. MySQL spec requires it but be
2310                    // generous.
2311                    if matches!(self.peek(), Token::Eq) {
2312                        self.advance();
2313                    }
2314                    // UNDEFINED / MERGE / TEMPTABLE — accept any
2315                    // bare ident; unknown values still parse so
2316                    // future MySQL versions don't break.
2317                    if matches!(
2318                        self.peek(),
2319                        Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2320                    ) {
2321                        self.advance();
2322                    }
2323                }
2324                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("definer") => {
2325                    self.advance(); // DEFINER
2326                    if matches!(self.peek(), Token::Eq) {
2327                        self.advance();
2328                    }
2329                    // User: quoted string, ident, OR ident @ host
2330                    // (host may itself be quoted or bare).
2331                    match self.peek().clone() {
2332                        Token::String(_) | Token::Ident(_) | Token::QuotedIdent(_) => {
2333                            self.advance();
2334                            // Optional `@host`.
2335                            if matches!(self.peek(), Token::At) {
2336                                self.advance();
2337                                if matches!(
2338                                    self.peek(),
2339                                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
2340                                ) {
2341                                    self.advance();
2342                                }
2343                            }
2344                        }
2345                        _ => {}
2346                    }
2347                }
2348                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sql") => {
2349                    // `SQL SECURITY {DEFINER|INVOKER}`. Only honoured
2350                    // when followed by SECURITY — the dispatcher must
2351                    // not consume a bare `SQL` token (it's not a
2352                    // legal CREATE prefix on its own).
2353                    let save = self.pos;
2354                    self.advance(); // SQL
2355                    if matches!(self.peek(), Token::Ident(s2) | Token::QuotedIdent(s2)
2356                        if s2.eq_ignore_ascii_case("security"))
2357                    {
2358                        self.advance(); // SECURITY
2359                        // DEFINER / INVOKER trailing ident.
2360                        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
2361                            self.advance();
2362                        }
2363                    } else {
2364                        // Not a SQL SECURITY clause — roll back and
2365                        // bail; the caller will error out cleanly.
2366                        self.pos = save;
2367                        return Ok(());
2368                    }
2369                }
2370                _ => return Ok(()),
2371            }
2372        }
2373    }
2374
2375    fn parse_if_not_exists(&mut self) -> bool {
2376        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2377        {
2378            let save = self.pos;
2379            self.advance();
2380            if matches!(self.peek(), Token::Not) {
2381                self.advance();
2382                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2383                {
2384                    self.advance();
2385                    return true;
2386                }
2387            }
2388            self.pos = save;
2389        }
2390        false
2391    }
2392
2393    fn parse_if_exists(&mut self) -> bool {
2394        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2395        {
2396            let save = self.pos;
2397            self.advance();
2398            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists"))
2399            {
2400                self.advance();
2401                return true;
2402            }
2403            self.pos = save;
2404        }
2405        false
2406    }
2407
2408    /// v7.12.4 — body of `CREATE [OR REPLACE] TRIGGER`. The
2409    /// `[OR REPLACE]` flag and the `TRIGGER` keyword have already
2410    /// been consumed.
2411    fn parse_create_trigger_after_keyword(
2412        &mut self,
2413        or_replace: bool,
2414    ) -> Result<Statement, ParseError> {
2415        let name = self.expect_ident_like()?;
2416        let timing = {
2417            let ident = self.expect_ident_like()?;
2418            if ident.eq_ignore_ascii_case("before") {
2419                TriggerTiming::Before
2420            } else if ident.eq_ignore_ascii_case("after") {
2421                TriggerTiming::After
2422            } else if ident.eq_ignore_ascii_case("instead") {
2423                let next = self.expect_ident_like()?;
2424                if !next.eq_ignore_ascii_case("of") {
2425                    return Err(self.err(alloc::format!(
2426                        "expected OF after INSTEAD in trigger timing, got {next:?}"
2427                    )));
2428                }
2429                TriggerTiming::InsteadOf
2430            } else {
2431                return Err(self.err(alloc::format!(
2432                    "expected BEFORE / AFTER / INSTEAD OF in trigger timing, got {ident:?}"
2433                )));
2434            }
2435        };
2436        // Events: INSERT [ OR UPDATE [ OR DELETE [ OR TRUNCATE ] ] ].
2437        // OR is a reserved keyword token (Token::Or), not an Ident.
2438        // v7.13.0 — after an UPDATE event we may optionally see
2439        // `OF col, col, …` (mailrs round-5 G7). Columns are
2440        // captured into `update_columns` once across the whole
2441        // events list; multiple `UPDATE OF` clauses are rejected.
2442        let mut events: Vec<TriggerEvent> = Vec::new();
2443        let mut update_columns: Vec<String> = Vec::new();
2444        let (first_ev, first_cols) = self.parse_trigger_event_with_optional_of()?;
2445        events.push(first_ev);
2446        if !first_cols.is_empty() {
2447            update_columns = first_cols;
2448        }
2449        while matches!(self.peek(), Token::Or) {
2450            self.advance();
2451            let (ev, cols) = self.parse_trigger_event_with_optional_of()?;
2452            events.push(ev);
2453            if !cols.is_empty() {
2454                if !update_columns.is_empty() {
2455                    return Err(
2456                        self.err("CREATE TRIGGER: `UPDATE OF cols` may appear at most once".into())
2457                    );
2458                }
2459                update_columns = cols;
2460            }
2461        }
2462        // ON <table>
2463        let tok = self.peek();
2464        let Token::On = tok else {
2465            return Err(self.err(alloc::format!(
2466                "expected ON after trigger events, got {tok:?}"
2467            )));
2468        };
2469        self.advance();
2470        let table = self.expect_ident_like()?;
2471        // FOR EACH ROW / FOR EACH STATEMENT. FOR is a reserved
2472        // keyword (Token::For); EACH / ROW / STATEMENT are bare
2473        // idents.
2474        if !matches!(self.peek(), Token::For) {
2475            return Err(self.err(alloc::format!(
2476                "expected FOR EACH ROW / STATEMENT, got {:?}",
2477                self.peek()
2478            )));
2479        }
2480        self.advance();
2481        let for_each = {
2482            let e = self.expect_ident_like()?;
2483            if !e.eq_ignore_ascii_case("each") {
2484                return Err(self.err(alloc::format!("expected EACH after FOR, got {e:?}")));
2485            }
2486            let unit = self.expect_ident_like()?;
2487            if unit.eq_ignore_ascii_case("row") {
2488                TriggerForEach::Row
2489            } else if unit.eq_ignore_ascii_case("statement") {
2490                TriggerForEach::Statement
2491            } else {
2492                return Err(self.err(alloc::format!(
2493                    "expected ROW / STATEMENT after FOR EACH, got {unit:?}"
2494                )));
2495            }
2496        };
2497        // EXECUTE FUNCTION/PROCEDURE name(...)
2498        let exec = self.expect_ident_like()?;
2499        if !exec.eq_ignore_ascii_case("execute") {
2500            return Err(self.err(alloc::format!(
2501                "expected EXECUTE FUNCTION/PROCEDURE in CREATE TRIGGER, got {exec:?}"
2502            )));
2503        }
2504        let fn_or_proc = self.expect_ident_like()?;
2505        if !(fn_or_proc.eq_ignore_ascii_case("function")
2506            || fn_or_proc.eq_ignore_ascii_case("procedure"))
2507        {
2508            return Err(self.err(alloc::format!(
2509                "expected FUNCTION / PROCEDURE after EXECUTE, got {fn_or_proc:?}"
2510            )));
2511        }
2512        let function = self.expect_ident_like()?;
2513        // Optional empty arg list `()`.
2514        if matches!(self.peek(), Token::LParen) {
2515            self.advance();
2516            if !matches!(self.peek(), Token::RParen) {
2517                return Err(self.err(alloc::format!(
2518                    "v7.12.4 trigger function calls take no args; got {:?}",
2519                    self.peek()
2520                )));
2521            }
2522            self.advance();
2523        }
2524        Ok(Statement::CreateTrigger(CreateTriggerStatement {
2525            name,
2526            or_replace,
2527            timing,
2528            events,
2529            table,
2530            for_each,
2531            function,
2532            update_columns,
2533        }))
2534    }
2535
2536    /// v7.13.0 — parse one trigger event, then optionally consume
2537    /// `OF col, col, …` after `UPDATE` (mailrs round-5 G7). Other
2538    /// events (INSERT/DELETE/TRUNCATE) don't accept the OF tail.
2539    fn parse_trigger_event_with_optional_of(
2540        &mut self,
2541    ) -> Result<(TriggerEvent, Vec<String>), ParseError> {
2542        let ev = self.parse_trigger_event()?;
2543        if !matches!(ev, TriggerEvent::Update) {
2544            return Ok((ev, Vec::new()));
2545        }
2546        // `OF` is a bare ident.
2547        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("of")) {
2548            return Ok((ev, Vec::new()));
2549        }
2550        self.advance(); // OF
2551        let mut cols: Vec<String> = Vec::new();
2552        loop {
2553            cols.push(self.expect_ident_like()?);
2554            if matches!(self.peek(), Token::Comma) {
2555                self.advance();
2556                continue;
2557            }
2558            break;
2559        }
2560        if cols.is_empty() {
2561            return Err(
2562                self.err("CREATE TRIGGER: `UPDATE OF` requires at least one column name".into())
2563            );
2564        }
2565        Ok((ev, cols))
2566    }
2567
2568    /// v7.12.4 — `BEGIN stmt; stmt; … END[;]` PL/pgSQL block.
2569    /// v7.12.6 — optional `DECLARE var TYPE [:= init];` prelude
2570    /// before `BEGIN`, and IF / RAISE / embedded SQL statements
2571    /// inside the body.
2572    /// Called by [`parse_plpgsql_body`] after the body's tokens
2573    /// have been lexed into this temporary parser.
2574    pub(crate) fn parse_plpgsql_block(&mut self) -> Result<PlPgSqlBlock, ParseError> {
2575        // v7.12.6 — optional DECLARE prelude.
2576        let declarations = if matches!(
2577            self.peek(),
2578            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("declare")
2579        ) {
2580            self.advance();
2581            self.parse_plpgsql_declare_block()?
2582        } else {
2583            Vec::new()
2584        };
2585        // BEGIN keyword (PL/pgSQL — distinct from the SQL
2586        // `BEGIN` transaction-start, but we can reuse the
2587        // reserved Token::Begin since the body is a separate
2588        // lex/parse context).
2589        if !matches!(self.peek(), Token::Begin) {
2590            return Err(self.err(alloc::format!(
2591                "expected BEGIN at start of plpgsql block, got {:?}",
2592                self.peek()
2593            )));
2594        }
2595        self.advance();
2596        let statements = self.parse_plpgsql_stmt_list_until_end()?;
2597        Ok(PlPgSqlBlock {
2598            declarations,
2599            statements,
2600        })
2601    }
2602
2603    /// v7.12.6 — parse the `DECLARE ... [var TYPE [:= init];]+`
2604    /// prelude. Caller has already consumed `DECLARE`. We stop
2605    /// reading entries when we hit `BEGIN`.
2606    fn parse_plpgsql_declare_block(&mut self) -> Result<Vec<PlPgSqlDeclare>, ParseError> {
2607        let mut out: Vec<PlPgSqlDeclare> = Vec::new();
2608        loop {
2609            if matches!(self.peek(), Token::Begin) {
2610                return Ok(out);
2611            }
2612            let name = self.expect_ident_like()?;
2613            let ty_token = self.expect_ident_like()?;
2614            let ty = match map_type_ident_to_column_type_name(&ty_token) {
2615                Some(t) => FunctionArgType::Typed(t),
2616                None => FunctionArgType::Raw(ty_token),
2617            };
2618            let default = match self.peek() {
2619                Token::ColonEq => {
2620                    self.advance();
2621                    Some(self.parse_expr(0)?)
2622                }
2623                Token::Eq => {
2624                    // PL/pgSQL also accepts `=` for the
2625                    // DECLARE default (PG treats them the same
2626                    // in this position).
2627                    self.advance();
2628                    Some(self.parse_expr(0)?)
2629                }
2630                _ => None,
2631            };
2632            // Mandatory `;` between declarations.
2633            if !matches!(self.peek(), Token::Semicolon) {
2634                return Err(self.err(alloc::format!(
2635                    "expected ; after DECLARE entry for {name:?}, got {:?}",
2636                    self.peek()
2637                )));
2638            }
2639            self.advance();
2640            out.push(PlPgSqlDeclare { name, ty, default });
2641        }
2642    }
2643
2644    /// v7.12.6 — parse PL/pgSQL statements up to (and consuming)
2645    /// the terminating `END;` (or `END IF;` etc — handled by the
2646    /// per-construct sub-parsers). Used by both the outer block
2647    /// and the IF/ELSE branch bodies.
2648    fn parse_plpgsql_stmt_list_until_end(&mut self) -> Result<Vec<PlPgSqlStmt>, ParseError> {
2649        let mut statements: Vec<PlPgSqlStmt> = Vec::new();
2650        loop {
2651            // Allow trailing semicolons + END.
2652            while matches!(self.peek(), Token::Semicolon) {
2653                self.advance();
2654            }
2655            // END / ELSE / ELSIF — handled by the caller.
2656            if matches!(
2657                self.peek(),
2658                Token::Ident(s) | Token::QuotedIdent(s)
2659                    if s.eq_ignore_ascii_case("end")
2660                        || s.eq_ignore_ascii_case("else")
2661                        || s.eq_ignore_ascii_case("elsif")
2662                        || s.eq_ignore_ascii_case("elseif")
2663            ) {
2664                return Ok(statements);
2665            }
2666            // Otherwise: one statement, then expect `;` or
2667            // a block-terminator keyword.
2668            let stmt = self.parse_plpgsql_stmt()?;
2669            statements.push(stmt);
2670            match self.peek() {
2671                Token::Semicolon => {
2672                    self.advance();
2673                }
2674                Token::Ident(s) | Token::QuotedIdent(s)
2675                    if s.eq_ignore_ascii_case("end")
2676                        || s.eq_ignore_ascii_case("else")
2677                        || s.eq_ignore_ascii_case("elsif")
2678                        || s.eq_ignore_ascii_case("elseif") =>
2679                {
2680                    // Final statement of the block without `;`.
2681                }
2682                other => {
2683                    return Err(self.err(alloc::format!(
2684                        "expected ; or END/ELSE/ELSIF after plpgsql statement, got {other:?}"
2685                    )));
2686                }
2687            }
2688        }
2689    }
2690
2691    fn parse_plpgsql_stmt(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2692        // RETURN keyword?
2693        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("return"))
2694        {
2695            self.advance();
2696            return self.parse_plpgsql_return();
2697        }
2698        // v7.12.6 — IF block.
2699        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("if"))
2700        {
2701            self.advance();
2702            return self.parse_plpgsql_if();
2703        }
2704        // v7.12.6 — RAISE.
2705        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("raise"))
2706        {
2707            self.advance();
2708            return self.parse_plpgsql_raise();
2709        }
2710        // v7.16.2 — `SELECT <projection> INTO <var> [FROM …]`
2711        // plpgsql-specific shape (mailrs round-10 migrate-042).
2712        // PG's SELECT INTO at top-level SQL would CREATE a new
2713        // table; inside plpgsql it ASSIGNS the query result to
2714        // a local variable. We detect the INTO at paren-depth
2715        // 0 between SELECT and the statement boundary; if
2716        // found, split the token stream into "pre-INTO
2717        // projection" + "var" + "post-INTO FROM/WHERE…" and
2718        // rebuild as a SelectInto with a regular SELECT body
2719        // (no INTO clause).
2720        if matches!(self.peek(), Token::Select)
2721            && let Some((select_body, var_name)) = self.try_parse_plpgsql_select_into()?
2722        {
2723            return Ok(PlPgSqlStmt::SelectInto {
2724                var: var_name,
2725                body: Box::new(select_body),
2726            });
2727        }
2728        // v7.12.6 — embedded SQL statements. INSERT/UPDATE/DELETE/
2729        // SELECT can appear directly inside a trigger body; we
2730        // recurse into the regular Statement parser, which will
2731        // stop at the trailing `;` (which our caller then
2732        // consumes).
2733        // v7.16.2 — top-level DO blocks (mailrs round-10 A.2)
2734        // also embed ALTER / CREATE / DROP statements; route
2735        // those through the same parser so the DO body parses
2736        // cleanly.
2737        if matches!(self.peek(), Token::Insert)
2738            || matches!(self.peek(), Token::Select)
2739            || matches!(self.peek(), Token::Create)
2740            || matches!(self.peek(), Token::Drop)
2741            || matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
2742                if s.eq_ignore_ascii_case("update")
2743                    || s.eq_ignore_ascii_case("delete")
2744                    || s.eq_ignore_ascii_case("alter"))
2745        {
2746            let stmt = self.parse_one_statement()?;
2747            return Ok(PlPgSqlStmt::EmbeddedSql(Box::new(stmt)));
2748        }
2749        // Otherwise: assignment. `NEW.col` / `OLD.col` / `var`
2750        // followed by `:=` and an expression.
2751        let target = self.parse_plpgsql_assign_target()?;
2752        // PL/pgSQL assignment uses `:=`. The lexer represents
2753        // this as a colon followed by `=`; check both shapes.
2754        match self.peek() {
2755            Token::ColonEq => {
2756                self.advance();
2757            }
2758            Token::Colon => {
2759                self.advance();
2760                if !matches!(self.peek(), Token::Eq) {
2761                    return Err(self.err(alloc::format!(
2762                        "expected := after plpgsql assign target, got `:` then {:?}",
2763                        self.peek()
2764                    )));
2765                }
2766                self.advance();
2767            }
2768            other => {
2769                return Err(self.err(alloc::format!(
2770                    "expected := after plpgsql assign target, got {other:?}"
2771                )));
2772            }
2773        }
2774        let value = self.parse_expr(0)?;
2775        Ok(PlPgSqlStmt::Assign { target, value })
2776    }
2777
2778    /// v7.12.6 — `IF cond THEN body [ELSIF cond THEN body]*
2779    /// [ELSE body] END IF`. `IF` keyword already consumed.
2780    fn parse_plpgsql_if(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2781        let mut branches: Vec<(Expr, Vec<PlPgSqlStmt>)> = Vec::new();
2782        let mut else_branch: Vec<PlPgSqlStmt> = Vec::new();
2783        loop {
2784            // <expr> THEN
2785            let cond = self.parse_expr(0)?;
2786            let then_kw = self.expect_ident_like()?;
2787            if !then_kw.eq_ignore_ascii_case("then") {
2788                return Err(self.err(alloc::format!(
2789                    "expected THEN after IF/ELSIF condition, got {then_kw:?}"
2790                )));
2791            }
2792            let body = self.parse_plpgsql_stmt_list_until_end()?;
2793            branches.push((cond, body));
2794            // Look at terminator: ELSIF/ELSEIF, ELSE, or END IF.
2795            match self.peek() {
2796                Token::Ident(s) | Token::QuotedIdent(s)
2797                    if s.eq_ignore_ascii_case("elsif") || s.eq_ignore_ascii_case("elseif") =>
2798                {
2799                    self.advance();
2800                    continue;
2801                }
2802                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("else") => {
2803                    self.advance();
2804                    else_branch = self.parse_plpgsql_stmt_list_until_end()?;
2805                    break;
2806                }
2807                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("end") => {
2808                    break;
2809                }
2810                other => {
2811                    return Err(self.err(alloc::format!(
2812                        "expected ELSIF / ELSE / END after IF branch body, got {other:?}"
2813                    )));
2814                }
2815            }
2816        }
2817        // Expect `END IF` (the END keyword is the one we're
2818        // looking at right now).
2819        let end_kw = self.expect_ident_like()?;
2820        if !end_kw.eq_ignore_ascii_case("end") {
2821            return Err(self.err(alloc::format!("expected END IF, got {end_kw:?}")));
2822        }
2823        let if_kw = self.expect_ident_like()?;
2824        if !if_kw.eq_ignore_ascii_case("if") {
2825            return Err(self.err(alloc::format!("expected END IF, got END {if_kw:?}")));
2826        }
2827        Ok(PlPgSqlStmt::If {
2828            branches,
2829            else_branch,
2830        })
2831    }
2832
2833    /// v7.12.6 — `RAISE { NOTICE | WARNING | INFO | LOG | DEBUG
2834    /// | EXCEPTION } '<message>' [, args]*`. The `RAISE` keyword
2835    /// is already consumed.
2836    fn parse_plpgsql_raise(&mut self) -> Result<PlPgSqlStmt, ParseError> {
2837        let lvl_ident = self.expect_ident_like()?;
2838        let level = match lvl_ident.to_ascii_lowercase().as_str() {
2839            "notice" => RaiseLevel::Notice,
2840            "warning" => RaiseLevel::Warning,
2841            "info" => RaiseLevel::Info,
2842            "log" => RaiseLevel::Log,
2843            "debug" => RaiseLevel::Debug,
2844            "exception" => RaiseLevel::Exception,
2845            other => {
2846                return Err(self.err(alloc::format!(
2847                    "expected RAISE level (NOTICE/WARNING/INFO/LOG/DEBUG/EXCEPTION), got {other:?}"
2848                )));
2849            }
2850        };
2851        // Message: required for v7.12.6. PG accepts a bare
2852        // RAISE-rethrow form (no message), reserved for future
2853        // RAISE-no-args support.
2854        let Token::String(msg) = self.peek() else {
2855            return Err(self.err(alloc::format!(
2856                "expected RAISE message string, got {:?}",
2857                self.peek()
2858            )));
2859        };
2860        let message = msg.clone();
2861        self.advance();
2862        // Optional comma-separated args (PG `%` format substitution).
2863        let mut args: Vec<Expr> = Vec::new();
2864        while matches!(self.peek(), Token::Comma) {
2865            self.advance();
2866            args.push(self.parse_expr(0)?);
2867        }
2868        Ok(PlPgSqlStmt::Raise {
2869            level,
2870            message,
2871            args,
2872        })
2873    }
2874
2875    /// v7.16.2 — scan ahead for a plpgsql-flavoured `SELECT
2876    /// <projection> INTO <var> [FROM …]` (mailrs round-10
2877    /// migrate-042). Returns `(rebuilt_select_without_into,
2878    /// var_name)` when the pattern matches; `None` for
2879    /// regular SELECTs (those go through the embedded-SQL
2880    /// path). Token-stream surgery so the rebuilt SELECT
2881    /// parses through the regular `parse_select_stmt`.
2882    #[allow(clippy::too_many_lines)]
2883    fn try_parse_plpgsql_select_into(
2884        &mut self,
2885    ) -> Result<Option<(SelectStatement, String)>, ParseError> {
2886        // Scan forward from `self.pos + 1` (past Token::Select)
2887        // for Token::Into at paren-depth 0, stopping at the
2888        // first `;`, `END`, `ELSE`, `ELSIF` keyword that would
2889        // end the plpgsql statement.
2890        let start = self.pos;
2891        let mut into_pos: Option<usize> = None;
2892        let mut depth: i32 = 0;
2893        let mut i = start + 1;
2894        while i < self.tokens.len() {
2895            match &self.tokens[i] {
2896                Token::LParen => depth += 1,
2897                Token::RParen => depth -= 1,
2898                Token::Semicolon if depth == 0 => break,
2899                Token::Ident(s)
2900                    if depth == 0
2901                        && (s.eq_ignore_ascii_case("end")
2902                            || s.eq_ignore_ascii_case("else")
2903                            || s.eq_ignore_ascii_case("elsif")) =>
2904                {
2905                    break;
2906                }
2907                Token::Into if depth == 0 => {
2908                    into_pos = Some(i);
2909                    break;
2910                }
2911                _ => {}
2912            }
2913            i += 1;
2914        }
2915        let Some(into_at) = into_pos else {
2916            return Ok(None);
2917        };
2918        // The token immediately after INTO must be the target
2919        // var ident; anything else (e.g. INSERT INTO table)
2920        // ruled out by the depth-0 check above. Capture it.
2921        let var = match self.tokens.get(into_at + 1) {
2922            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
2923            other => {
2924                return Err(self.err(alloc::format!(
2925                    "expected variable name after SELECT … INTO, got {other:?}"
2926                )));
2927            }
2928        };
2929        // Find the end of the plpgsql SELECT INTO statement —
2930        // same boundary rules as the depth-0 scan above.
2931        let mut end = into_at + 2;
2932        let mut depth2: i32 = 0;
2933        while end < self.tokens.len() {
2934            match &self.tokens[end] {
2935                Token::LParen => depth2 += 1,
2936                Token::RParen => depth2 -= 1,
2937                Token::Semicolon if depth2 == 0 => break,
2938                Token::Ident(s)
2939                    if depth2 == 0
2940                        && (s.eq_ignore_ascii_case("end")
2941                            || s.eq_ignore_ascii_case("else")
2942                            || s.eq_ignore_ascii_case("elsif")) =>
2943                {
2944                    break;
2945                }
2946                _ => {}
2947            }
2948            end += 1;
2949        }
2950        // Rebuild a token stream that represents the SELECT
2951        // WITHOUT the INTO clause: [SELECT .. up-to-INTO] + [
2952        // post-var tokens up to statement end]. Run the
2953        // regular `parse_select_stmt` against it.
2954        let mut rebuilt: Vec<Token> = Vec::with_capacity(end - start);
2955        for j in start..into_at {
2956            rebuilt.push(self.tokens[j].clone());
2957        }
2958        for j in (into_at + 2)..end {
2959            rebuilt.push(self.tokens[j].clone());
2960        }
2961        rebuilt.push(Token::Eof);
2962        let saved_pos = self.pos;
2963        let saved_tokens = core::mem::replace(&mut self.tokens, rebuilt);
2964        self.pos = 0;
2965        // parse_select_stmt → parse_bare_select consumes Token::Select itself.
2966        if !matches!(self.peek(), Token::Select) {
2967            self.tokens = saved_tokens;
2968            self.pos = saved_pos;
2969            return Err(self.err("plpgsql SELECT … INTO: rebuilt stream missing SELECT".into()));
2970        }
2971        let sel = self.parse_select_stmt();
2972        self.tokens = saved_tokens;
2973        self.pos = end;
2974        let sel = sel?;
2975        let Statement::Select(body) = sel else {
2976            return Err(self.err(alloc::format!(
2977                "plpgsql SELECT … INTO: rebuilt SELECT did not produce a Select node, got {sel:?}"
2978            )));
2979        };
2980        Ok(Some((body, var)))
2981    }
2982
2983    fn parse_plpgsql_assign_target(&mut self) -> Result<AssignTarget, ParseError> {
2984        // v7.16.1 — read the head token DIRECTLY rather than
2985        // via `expect_ident_like`. The v7.14.0 schema-qualifier
2986        // strip (`public.t` → `t`) inside `expect_ident_like`
2987        // greedily consumes any `ident . ident` pair, which
2988        // silently turned every `NEW.col := …` /
2989        // `OLD.col := …` plpgsql assignment into a Local("col")
2990        // assignment — the head "new"/"old" was eaten as if it
2991        // were a schema name and the Dot was consumed too, so
2992        // this function's own `peek() == Token::Dot` check
2993        // below never fired. Every BEFORE trigger that rewrote
2994        // a NEW cell was a silent no-op for two major releases
2995        // (v7.14.0 + v7.15.0) until the e2e_trigger workspace-
2996        // gate failures were investigated as v7.16.1 backlog.
2997        let head = match self.advance() {
2998            Token::Ident(s) | Token::QuotedIdent(s) => s,
2999            other => {
3000                return Err(self.err(alloc::format!(
3001                    "expected NEW / OLD / <local_var> as plpgsql assign target, got {other:?}"
3002                )));
3003            }
3004        };
3005        if matches!(self.peek(), Token::Dot) {
3006            self.advance();
3007            let col = self.expect_ident_like()?;
3008            if head.eq_ignore_ascii_case("new") {
3009                return Ok(AssignTarget::NewColumn(col));
3010            }
3011            if head.eq_ignore_ascii_case("old") {
3012                return Ok(AssignTarget::OldColumn(col));
3013            }
3014            return Err(self.err(alloc::format!(
3015                "plpgsql assign target must be NEW.<col> / OLD.<col> / <local_var>; \
3016                 got {head:?}.<col>"
3017            )));
3018        }
3019        Ok(AssignTarget::Local(head))
3020    }
3021
3022    fn parse_plpgsql_return(&mut self) -> Result<PlPgSqlStmt, ParseError> {
3023        // RETURN NEW / OLD / NULL — bare-ident forms.
3024        match self.peek() {
3025            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("new") => {
3026                self.advance();
3027                return Ok(PlPgSqlStmt::Return(ReturnTarget::New));
3028            }
3029            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("old") => {
3030                self.advance();
3031                return Ok(PlPgSqlStmt::Return(ReturnTarget::Old));
3032            }
3033            Token::Null => {
3034                self.advance();
3035                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3036            }
3037            // Bare `RETURN;` (no value) — treated as `RETURN NULL`
3038            // per PL/pgSQL convention.
3039            Token::Semicolon => {
3040                return Ok(PlPgSqlStmt::Return(ReturnTarget::Null));
3041            }
3042            _ => {}
3043        }
3044        // Fall through: parse a full expression.
3045        let e = self.parse_expr(0)?;
3046        Ok(PlPgSqlStmt::Return(ReturnTarget::Expr(e)))
3047    }
3048
3049    fn parse_trigger_event(&mut self) -> Result<TriggerEvent, ParseError> {
3050        // INSERT is a reserved Token; UPDATE / DELETE / TRUNCATE
3051        // are ident-shaped (the parser keys off case-insensitive
3052        // match — same shape used by the top-level Update / Delete
3053        // dispatchers at parse_one_statement).
3054        if matches!(self.peek(), Token::Insert) {
3055            self.advance();
3056            return Ok(TriggerEvent::Insert);
3057        }
3058        match self.peek() {
3059            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3060                self.advance();
3061                Ok(TriggerEvent::Update)
3062            }
3063            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3064                self.advance();
3065                Ok(TriggerEvent::Delete)
3066            }
3067            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("truncate") => {
3068                self.advance();
3069                Ok(TriggerEvent::Truncate)
3070            }
3071            other => Err(self.err(alloc::format!(
3072                "expected INSERT / UPDATE / DELETE / TRUNCATE in trigger event list, got {other:?}"
3073            ))),
3074        }
3075    }
3076
3077    /// v6.1.2 → v6.1.3 — `CREATE PUBLICATION <name>` body. Accepts:
3078    ///   - (no clause) → implicit `FOR ALL TABLES`
3079    ///   - `FOR ALL TABLES`
3080    ///   - `FOR ALL TABLES EXCEPT t1, t2, …` (v6.1.3)
3081    ///   - `FOR TABLE t1, t2, …` (v6.1.3) — `FOR TABLES …` also
3082    ///     accepted (PG accepts both forms in PG 19).
3083    fn parse_create_publication_after_keyword(&mut self) -> Result<Statement, ParseError> {
3084        let name = self.expect_ident_or_string()?;
3085        // Bare DDL maps to FOR ALL TABLES — matches the v6.1.2
3086        // shape so existing publications keep parsing identically.
3087        let scope = if matches!(self.peek(), Token::For) {
3088            self.advance();
3089            if matches!(self.peek(), Token::All) {
3090                self.advance();
3091                if !matches!(self.peek(), Token::Tables) {
3092                    return Err(self.err(format!(
3093                        "expected TABLES after FOR ALL, got {:?}",
3094                        self.peek()
3095                    )));
3096                }
3097                self.advance();
3098                if matches!(self.peek(), Token::Except) {
3099                    self.advance();
3100                    let tables = self.parse_publication_table_list()?;
3101                    PublicationScope::AllTablesExcept(tables)
3102                } else {
3103                    PublicationScope::AllTables
3104                }
3105            } else if matches!(self.peek(), Token::Table | Token::Tables) {
3106                // PG 19 accepts both `FOR TABLE …` (singular) and
3107                // `FOR TABLES …` (plural); SPG matches.
3108                self.advance();
3109                let tables = self.parse_publication_table_list()?;
3110                PublicationScope::ForTables(tables)
3111            } else {
3112                return Err(self.err(format!(
3113                    "expected ALL TABLES or TABLE <list> after FOR, got {:?}",
3114                    self.peek()
3115                )));
3116            }
3117        } else {
3118            PublicationScope::AllTables
3119        };
3120        Ok(Statement::CreatePublication(CreatePublicationStatement {
3121            name,
3122            scope,
3123        }))
3124    }
3125
3126    /// v6.1.3 — Comma-separated identifier list for the publication
3127    /// FOR-clause. Requires at least one entry; empty list is a
3128    /// parse error (PG behaviour). Quoted idents are accepted; the
3129    /// names round-trip through `Display` as `quote_ident(name)`.
3130    fn parse_publication_table_list(&mut self) -> Result<Vec<String>, ParseError> {
3131        let first = self.expect_ident_like()?;
3132        let mut out = alloc::vec![first];
3133        while matches!(self.peek(), Token::Comma) {
3134            self.advance();
3135            out.push(self.expect_ident_like()?);
3136        }
3137        Ok(out)
3138    }
3139
3140    /// v6.1.4 — `CREATE SUBSCRIPTION <name>
3141    ///                 CONNECTION '<conn>'
3142    ///                 PUBLICATION <pub> [, <pub> ...]`.
3143    ///
3144    /// The clause order is fixed (CONNECTION first, then
3145    /// PUBLICATION) to match PG. No WITH-options accepted in
3146    /// v6.1.4 — `enabled` defaults to true, no other knobs ship.
3147    fn parse_create_subscription_after_keyword(&mut self) -> Result<Statement, ParseError> {
3148        let name = self.expect_ident_or_string()?;
3149        if !matches!(self.peek(), Token::Connection) {
3150            return Err(self.err(format!(
3151                "expected CONNECTION after CREATE SUBSCRIPTION <name>, got {:?}",
3152                self.peek()
3153            )));
3154        }
3155        self.advance();
3156        let conn_str = self.expect_string_literal()?;
3157        if !matches!(self.peek(), Token::Publication) {
3158            return Err(self.err(format!(
3159                "expected PUBLICATION after CONNECTION '<conn>', got {:?}",
3160                self.peek()
3161            )));
3162        }
3163        self.advance();
3164        // Reuse the publication FOR-list parser shape: at least one
3165        // identifier, comma-separated.
3166        let first = self.expect_ident_like()?;
3167        let mut publications = alloc::vec![first];
3168        while matches!(self.peek(), Token::Comma) {
3169            self.advance();
3170            publications.push(self.expect_ident_like()?);
3171        }
3172        Ok(Statement::CreateSubscription(CreateSubscriptionStatement {
3173            name,
3174            conn_str,
3175            publications,
3176        }))
3177    }
3178
3179    /// v6.1.7 — `WAIT FOR WAL POSITION <pos> [WITH TIMEOUT <ms>]`.
3180    /// All keywords after `WAIT` are bare idents in v6.1.x; no
3181    /// lexer churn. Both `<pos>` and `<ms>` are positive integers
3182    /// that fit `u64`.
3183    /// v7.12.1 — parameter name in `SET <name>` may be dotted
3184    /// (`pg_catalog.default_text_search_config` etc).
3185    fn parse_set_param_name(&mut self) -> Result<String, ParseError> {
3186        let mut name = self.expect_ident_like()?;
3187        while matches!(self.peek(), Token::Dot) {
3188            self.advance();
3189            let next = self.expect_ident_like()?;
3190            name.push('.');
3191            name.push_str(&next);
3192        }
3193        Ok(name.to_ascii_lowercase())
3194    }
3195
3196    fn parse_set_value(&mut self) -> Result<crate::ast::SetValue, ParseError> {
3197        match self.advance() {
3198            Token::String(s) => Ok(crate::ast::SetValue::String(s)),
3199            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("default") => {
3200                Ok(crate::ast::SetValue::Default)
3201            }
3202            Token::Ident(s) | Token::QuotedIdent(s) => {
3203                let mut accum = s;
3204                while matches!(self.peek(), Token::Dot) {
3205                    self.advance();
3206                    let next = self.expect_ident_like()?;
3207                    accum.push('.');
3208                    accum.push_str(&next);
3209                }
3210                Ok(crate::ast::SetValue::Ident(accum))
3211            }
3212            Token::Integer(n) => Ok(crate::ast::SetValue::Number(n.to_string())),
3213            Token::Float(f) => Ok(crate::ast::SetValue::Number(f.to_string())),
3214            // v7.22 (mailrs round-13 gap 2) — PG boolean parameter
3215            // spellings that lex as keyword tokens, not idents:
3216            // `SET standard_conforming_strings = on` is in every
3217            // pg_dump preamble (`off` already lexes as an ident).
3218            Token::On => Ok(crate::ast::SetValue::Ident("on".to_string())),
3219            Token::True => Ok(crate::ast::SetValue::Ident("true".to_string())),
3220            Token::False => Ok(crate::ast::SetValue::Ident("false".to_string())),
3221            // v7.14.0 — MySQL session/user variable RHS
3222            // (e.g. `SET OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS`).
3223            // Wrap as Ident so the SET handler can record it; the
3224            // engine treats `@VAR` / `@@VAR` values as opaque
3225            // strings.
3226            Token::SessionVar(s) => Ok(crate::ast::SetValue::Ident(s)),
3227            // v7.14.0 — `SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO,STRICT_TRANS_TABLES'`
3228            // is the common MySQL preamble shape. Allow a `+` or
3229            // `-` prefix on negative numerics for parity with PG
3230            // (some param defaults are negative).
3231            Token::Minus => match self.advance() {
3232                Token::Integer(n) => Ok(crate::ast::SetValue::Number(alloc::format!("-{n}"))),
3233                Token::Float(f) => Ok(crate::ast::SetValue::Number(alloc::format!("-{f}"))),
3234                other => Err(self.err(format!(
3235                    "expected numeric after `-` in SET value, got {other:?}"
3236                ))),
3237            },
3238            other => Err(self.err(format!(
3239                "expected literal, identifier, or DEFAULT after `=` in SET, got {other:?}"
3240            ))),
3241        }
3242    }
3243
3244    fn parse_wait_after_keyword(&mut self) -> Result<Statement, ParseError> {
3245        // FOR is a v6.1.2-reserved keyword (Token::For). The
3246        // other two are bare idents — they've never needed lexer
3247        // support and we keep it that way.
3248        if !matches!(self.peek(), Token::For) {
3249            return Err(self.err(format!("expected FOR after WAIT, got {:?}", self.peek())));
3250        }
3251        self.advance();
3252        self.expect_keyword_ident("wal")?;
3253        self.expect_keyword_ident("position")?;
3254        let pos = self.expect_u64_literal()?;
3255        let timeout_ms = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("with"))
3256        {
3257            self.advance();
3258            self.expect_keyword_ident("timeout")?;
3259            Some(self.expect_u64_literal()?)
3260        } else {
3261            None
3262        };
3263        Ok(Statement::WaitForWalPosition { pos, timeout_ms })
3264    }
3265
3266    /// v6.1.7 helper — consume a `Token::Integer` and check it
3267    /// fits `u64`. WAL positions and millisecond timeouts are
3268    /// non-negative.
3269    fn expect_u64_literal(&mut self) -> Result<u64, ParseError> {
3270        match self.advance() {
3271            Token::Integer(n) if n >= 0 => Ok(n as u64),
3272            Token::Integer(n) => Err(ParseError {
3273                message: format!("expected non-negative integer, got {n}"),
3274                token_pos: self.pos.saturating_sub(1),
3275            }),
3276            other => Err(ParseError {
3277                message: format!("expected integer literal, got {other:?}"),
3278                token_pos: self.pos.saturating_sub(1),
3279            }),
3280        }
3281    }
3282
3283    /// `CREATE USER` body — name + WITH PASSWORD '<pw>' + optional
3284    /// ROLE '<role>' (defaults to readonly). All string slots accept
3285    /// either a quoted ident or a quoted string literal.
3286    fn parse_create_user_after_keyword(&mut self) -> Result<Statement, ParseError> {
3287        let name = self.expect_ident_or_string()?;
3288        self.expect_keyword_ident("with")?;
3289        self.expect_keyword_ident("password")?;
3290        let password = self.expect_string_literal()?;
3291        let role = if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
3292            && s.eq_ignore_ascii_case("role")
3293        {
3294            self.advance();
3295            self.expect_string_literal()?
3296        } else {
3297            "readonly".to_string()
3298        };
3299        Ok(Statement::CreateUser(crate::ast::CreateUserStatement {
3300            name,
3301            password,
3302            role,
3303        }))
3304    }
3305
3306    /// v4.4 `UPDATE <table> SET col = expr [, col = expr]* [WHERE cond]`.
3307    /// Caller already consumed the leading `UPDATE` ident.
3308    fn parse_update_after_keyword(&mut self) -> Result<Statement, ParseError> {
3309        let table = self.expect_ident_like()?;
3310        self.expect_keyword_ident("set")?;
3311        let mut assignments = Vec::new();
3312        loop {
3313            let col = self.expect_ident_like()?;
3314            if !matches!(self.peek(), Token::Eq) {
3315                return Err(self.err(format!(
3316                    "expected `=` after column name in UPDATE SET, got {:?}",
3317                    self.peek()
3318                )));
3319            }
3320            self.advance();
3321            let value = self.parse_expr(0)?;
3322            assignments.push((col, value));
3323            if matches!(self.peek(), Token::Comma) {
3324                self.advance();
3325                continue;
3326            }
3327            break;
3328        }
3329        let where_ = if matches!(self.peek(), Token::Where) {
3330            self.advance();
3331            Some(self.parse_expr(0)?)
3332        } else {
3333            None
3334        };
3335        let returning = self.parse_optional_returning()?;
3336        Ok(Statement::Update(crate::ast::UpdateStatement {
3337            table,
3338            assignments,
3339            where_,
3340            returning,
3341        }))
3342    }
3343
3344    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Caller already consumed
3345    /// the leading `DELETE` ident.
3346    fn parse_delete_after_keyword(&mut self) -> Result<Statement, ParseError> {
3347        if !matches!(self.peek(), Token::From) {
3348            return Err(self.err(format!("expected FROM after DELETE, got {:?}", self.peek())));
3349        }
3350        self.advance();
3351        let table = self.expect_ident_like()?;
3352        let where_ = if matches!(self.peek(), Token::Where) {
3353            self.advance();
3354            Some(self.parse_expr(0)?)
3355        } else {
3356            None
3357        };
3358        let returning = self.parse_optional_returning()?;
3359        Ok(Statement::Delete(crate::ast::DeleteStatement {
3360            table,
3361            where_,
3362            returning,
3363        }))
3364    }
3365
3366    /// v7.17.0 Phase 3.P0-42 — parse `MERGE INTO <target> [alias]
3367    /// USING <source> [alias] ON <expr> WHEN [NOT] MATCHED [AND
3368    /// <expr>] THEN <action> [WHEN …]` after the leading `MERGE`
3369    /// keyword. v7.17 surface:
3370    ///   * source: table reference (subquery source is a follow-up)
3371    ///   * actions: UPDATE SET / DELETE / DO NOTHING (matched);
3372    ///     INSERT (cols) VALUES (vals) / DO NOTHING (not matched)
3373    ///   * AND-conditioned WHEN clauses; clauses tried in declaration
3374    ///     order
3375    fn parse_merge_after_keyword(&mut self) -> Result<Statement, ParseError> {
3376        // INTO
3377        let is_into_kw = matches!(self.peek(), Token::Into)
3378            || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("into"));
3379        if !is_into_kw {
3380            return Err(self.err(format!("expected INTO after MERGE, got {:?}", self.peek())));
3381        }
3382        self.advance();
3383        let target = self.expect_ident_like()?;
3384        // Optional alias — bare ident before USING.
3385        let target_alias = match self.peek() {
3386            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("using") => {
3387                Some(self.expect_ident_like()?)
3388            }
3389            _ => None,
3390        };
3391        // USING
3392        let is_using_kw = matches!(
3393            self.peek(),
3394            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("using")
3395        );
3396        if !is_using_kw {
3397            return Err(self.err(format!(
3398                "expected USING after MERGE INTO target, got {:?}",
3399                self.peek()
3400            )));
3401        }
3402        self.advance();
3403        let source = self.expect_ident_like()?;
3404        let source_alias = match self.peek() {
3405            Token::Ident(s) | Token::QuotedIdent(s) if !s.eq_ignore_ascii_case("on") => {
3406                Some(self.expect_ident_like()?)
3407            }
3408            _ => None,
3409        };
3410        // ON
3411        if !matches!(self.peek(), Token::On) {
3412            return Err(self.err(format!(
3413                "expected ON after MERGE … USING source, got {:?}",
3414                self.peek()
3415            )));
3416        }
3417        self.advance();
3418        let on = self.parse_expr(0)?;
3419        // One or more WHEN clauses.
3420        let mut clauses: Vec<crate::ast::MergeWhenClause> = Vec::new();
3421        loop {
3422            let is_when_kw = matches!(
3423                self.peek(),
3424                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("when")
3425            );
3426            if !is_when_kw {
3427                break;
3428            }
3429            self.advance(); // WHEN
3430            // [NOT] MATCHED
3431            let matched = if matches!(self.peek(), Token::Not) {
3432                self.advance();
3433                crate::ast::MergeMatched::NotMatched
3434            } else {
3435                crate::ast::MergeMatched::Matched
3436            };
3437            let is_matched_kw = matches!(
3438                self.peek(),
3439                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("matched")
3440            );
3441            if !is_matched_kw {
3442                return Err(self.err(format!(
3443                    "expected MATCHED in WHEN clause, got {:?}",
3444                    self.peek()
3445                )));
3446            }
3447            self.advance();
3448            // Optional AND <expr>
3449            let condition = if matches!(self.peek(), Token::And) {
3450                self.advance();
3451                Some(self.parse_expr(0)?)
3452            } else {
3453                None
3454            };
3455            // THEN
3456            let is_then_kw = matches!(
3457                self.peek(),
3458                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("then")
3459            );
3460            if !is_then_kw {
3461                return Err(self.err(format!(
3462                    "expected THEN in WHEN clause, got {:?}",
3463                    self.peek()
3464                )));
3465            }
3466            self.advance();
3467            // Action: INSERT / UPDATE / DELETE / DO NOTHING
3468            let action = match self.peek().clone() {
3469                Token::Insert => {
3470                    self.advance();
3471                    // (cols)
3472                    if !matches!(self.peek(), Token::LParen) {
3473                        return Err(self.err(format!(
3474                            "expected '(' after INSERT in MERGE, got {:?}",
3475                            self.peek()
3476                        )));
3477                    }
3478                    self.advance();
3479                    let mut columns: Vec<String> = Vec::new();
3480                    loop {
3481                        columns.push(self.expect_ident_like()?);
3482                        if matches!(self.peek(), Token::Comma) {
3483                            self.advance();
3484                            continue;
3485                        }
3486                        break;
3487                    }
3488                    if !matches!(self.peek(), Token::RParen) {
3489                        return Err(self.err(format!(
3490                            "expected ')' after INSERT column list, got {:?}",
3491                            self.peek()
3492                        )));
3493                    }
3494                    self.advance();
3495                    // VALUES (...)
3496                    if !matches!(self.peek(), Token::Values) {
3497                        return Err(self.err(format!(
3498                            "expected VALUES in MERGE INSERT, got {:?}",
3499                            self.peek()
3500                        )));
3501                    }
3502                    self.advance();
3503                    if !matches!(self.peek(), Token::LParen) {
3504                        return Err(self.err(format!(
3505                            "expected '(' after VALUES in MERGE INSERT, got {:?}",
3506                            self.peek()
3507                        )));
3508                    }
3509                    self.advance();
3510                    let mut values: Vec<crate::ast::Expr> = Vec::new();
3511                    loop {
3512                        values.push(self.parse_expr(0)?);
3513                        if matches!(self.peek(), Token::Comma) {
3514                            self.advance();
3515                            continue;
3516                        }
3517                        break;
3518                    }
3519                    if !matches!(self.peek(), Token::RParen) {
3520                        return Err(self.err(format!(
3521                            "expected ')' after MERGE INSERT values, got {:?}",
3522                            self.peek()
3523                        )));
3524                    }
3525                    self.advance();
3526                    if columns.len() != values.len() {
3527                        return Err(self.err(format!(
3528                            "MERGE INSERT column count ({}) ≠ value count ({})",
3529                            columns.len(),
3530                            values.len()
3531                        )));
3532                    }
3533                    crate::ast::MergeAction::Insert { columns, values }
3534                }
3535                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
3536                    self.advance();
3537                    // SET
3538                    let is_set_kw = matches!(
3539                        self.peek(),
3540                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set")
3541                    );
3542                    if !is_set_kw {
3543                        return Err(self.err(format!(
3544                            "expected SET after UPDATE in MERGE, got {:?}",
3545                            self.peek()
3546                        )));
3547                    }
3548                    self.advance();
3549                    let mut assignments: Vec<(String, crate::ast::Expr)> = Vec::new();
3550                    loop {
3551                        let col = self.expect_ident_like()?;
3552                        if !matches!(self.peek(), Token::Eq) {
3553                            return Err(self.err(format!(
3554                                "expected '=' in MERGE UPDATE assignment, got {:?}",
3555                                self.peek()
3556                            )));
3557                        }
3558                        self.advance();
3559                        let expr = self.parse_expr(0)?;
3560                        assignments.push((col, expr));
3561                        if matches!(self.peek(), Token::Comma) {
3562                            self.advance();
3563                            continue;
3564                        }
3565                        break;
3566                    }
3567                    crate::ast::MergeAction::Update { assignments }
3568                }
3569                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("delete") => {
3570                    self.advance();
3571                    crate::ast::MergeAction::Delete
3572                }
3573                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {
3574                    self.advance();
3575                    let is_nothing_kw = matches!(
3576                        self.peek(),
3577                        Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing")
3578                    );
3579                    if !is_nothing_kw {
3580                        return Err(self.err(format!(
3581                            "expected NOTHING after DO in MERGE clause, got {:?}",
3582                            self.peek()
3583                        )));
3584                    }
3585                    self.advance();
3586                    crate::ast::MergeAction::DoNothing
3587                }
3588                other => {
3589                    return Err(self.err(format!(
3590                        "expected INSERT / UPDATE / DELETE / DO NOTHING in MERGE clause, got {other:?}"
3591                    )));
3592                }
3593            };
3594            clauses.push(crate::ast::MergeWhenClause {
3595                matched,
3596                condition,
3597                action,
3598            });
3599        }
3600        if clauses.is_empty() {
3601            return Err(self.err(String::from("MERGE requires at least one WHEN clause")));
3602        }
3603        Ok(Statement::Merge(crate::ast::MergeStatement {
3604            target,
3605            target_alias,
3606            source,
3607            source_alias,
3608            on,
3609            clauses,
3610        }))
3611    }
3612
3613    /// v7.9.4 — parse the optional trailing `RETURNING <projection>`
3614    /// clause on INSERT / UPDATE / DELETE. Same projection grammar
3615    /// as SELECT, so `RETURNING *`, `RETURNING col`,
3616    /// `RETURNING expr AS alias`, and `RETURNING a, b, c` all work.
3617    fn parse_optional_returning(
3618        &mut self,
3619    ) -> Result<Option<Vec<crate::ast::SelectItem>>, ParseError> {
3620        let is_returning_kw = matches!(
3621            self.peek(),
3622            Token::Ident(s) if s.eq_ignore_ascii_case("returning")
3623        );
3624        if !is_returning_kw {
3625            return Ok(None);
3626        }
3627        self.advance();
3628        let mut items = Vec::new();
3629        loop {
3630            items.push(self.parse_select_item()?);
3631            if matches!(self.peek(), Token::Comma) {
3632                self.advance();
3633                continue;
3634            }
3635            break;
3636        }
3637        Ok(Some(items))
3638    }
3639
3640    /// v6.0.4 — parse the tail of an ALTER statement after the
3641    /// leading `ALTER` keyword has been consumed. Only one form is
3642    /// supported in v6.0.4:
3643    ///
3644    /// ```text
3645    /// ALTER INDEX <name> REBUILD [WITH (encoding = <enc>)]
3646    /// ```
3647    fn parse_alter_after_keyword(&mut self) -> Result<Statement, ParseError> {
3648        // ALTER INDEX <name> ... | ALTER TABLE <name> SET hot_tier_bytes = <n>
3649        // v7.14.0 — `ALTER TABLE ONLY` modifier (PG partition-
3650        // exclusion) is accepted by stripping the `ONLY` keyword
3651        // before the table parse.
3652        // v7.14.0 — `ALTER SEQUENCE / ALTER VIEW / ALTER OWNER`
3653        // and the long PG-dump tail are accepted as no-ops.
3654        match self.advance() {
3655            Token::Index => {}
3656            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("index") => {}
3657            // v6.7.2 — ALTER TABLE t SET hot_tier_bytes = X
3658            // v7.14.0 — ALTER TABLE ONLY t … strip the `ONLY`.
3659            Token::Table => {
3660                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3661                    self.advance();
3662                }
3663                return self.parse_alter_table_after_keyword();
3664            }
3665            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("table") => {
3666                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("only")) {
3667                    self.advance();
3668                }
3669                return self.parse_alter_table_after_keyword();
3670            }
3671            // v7.17.0 — ALTER SEQUENCE name <options>. Moved out
3672            // of the silent-noop tail.
3673            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("sequence") => {
3674                return self.parse_alter_sequence_after_keyword();
3675            }
3676            // v7.14.0 — ALTER VIEW / ALTER FUNCTION / ALTER TYPE /
3677            // ALTER DOMAIN / ALTER DATABASE / ALTER USER / ALTER
3678            // ROLE / ALTER SCHEMA / ALTER OWNER / ALTER DEFAULT
3679            // PRIVILEGES — accept as no-op so pg_dump's tail loads.
3680            // v7.17.0 NOTE: ALTER SEQUENCE moved out (above).
3681            Token::Ident(s) | Token::QuotedIdent(s)
3682                if matches!(
3683                    s.to_ascii_lowercase().as_str(),
3684                    "view"
3685                        | "function"
3686                        | "type"
3687                        | "domain"
3688                        | "database"
3689                        | "role"
3690                        | "schema"
3691                        | "owner"
3692                        | "default"
3693                        | "extension"
3694                        | "materialized"
3695                        | "policy"
3696                        | "publication"
3697                        | "subscription"
3698                ) =>
3699            {
3700                self.consume_until_statement_boundary();
3701                return Ok(Statement::Empty);
3702            }
3703            other => {
3704                return Err(self.err(format!(
3705                    "expected INDEX / TABLE / SEQUENCE / VIEW / FUNCTION / TYPE / OWNER / etc \
3706                     after ALTER, got {other:?}"
3707                )));
3708            }
3709        }
3710        // v7.16.2 — optional `IF EXISTS` after ALTER INDEX
3711        // (mailrs migrate-042 ships these). The presence of an
3712        // IF EXISTS makes the subsequent name lookup tolerate
3713        // a missing index — engine returns CommandOk no-op.
3714        let if_exists = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3715            let next = self.tokens.get(self.pos + 1);
3716            if matches!(next, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
3717                self.advance();
3718                self.advance();
3719                true
3720            } else {
3721                false
3722            }
3723        } else {
3724            false
3725        };
3726        let name = self.expect_ident_like()?;
3727        // v7.16.2 — RENAME TO new_name shape (mailrs migrate-042).
3728        // Detect BEFORE the REBUILD path so the existing REBUILD
3729        // arm stays untouched.
3730        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("rename")) {
3731            self.advance();
3732            if matches!(self.peek(), Token::To) {
3733                self.advance();
3734            } else {
3735                self.expect_keyword_ident("to")?;
3736            }
3737            let new = self.expect_ident_like()?;
3738            return Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3739                name,
3740                target: crate::ast::AlterIndexTarget::Rename { new, if_exists },
3741            }));
3742        }
3743        // REBUILD
3744        self.expect_keyword_ident("rebuild")?;
3745        // Optional: WITH (encoding = <enc>)
3746        let encoding = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
3747            self.advance();
3748            if !matches!(self.peek(), Token::LParen) {
3749                return Err(self.err(format!(
3750                    "expected '(' after WITH in ALTER INDEX REBUILD, got {:?}",
3751                    self.peek()
3752                )));
3753            }
3754            self.advance();
3755            self.expect_keyword_ident("encoding")?;
3756            if !matches!(self.peek(), Token::Eq) {
3757                return Err(self.err(format!(
3758                    "expected '=' after encoding in ALTER INDEX REBUILD, got {:?}",
3759                    self.peek()
3760                )));
3761            }
3762            self.advance();
3763            let enc_ident = match self.advance() {
3764                Token::Ident(s) | Token::QuotedIdent(s) => s,
3765                other => {
3766                    return Err(self.err(format!("expected encoding name after =, got {other:?}")));
3767                }
3768            };
3769            let enc = match enc_ident.to_ascii_lowercase().as_str() {
3770                "f32" => VecEncoding::F32,
3771                "sq8" => VecEncoding::Sq8,
3772                "half" => VecEncoding::F16,
3773                other => {
3774                    return Err(self.err(format!(
3775                        "unknown vector encoding {other:?} in ALTER INDEX REBUILD; supported: F32, SQ8, HALF"
3776                    )));
3777                }
3778            };
3779            if !matches!(self.peek(), Token::RParen) {
3780                return Err(self.err(format!(
3781                    "expected ')' after encoding value, got {:?}",
3782                    self.peek()
3783                )));
3784            }
3785            self.advance();
3786            Some(enc)
3787        } else {
3788            None
3789        };
3790        Ok(Statement::AlterIndex(crate::ast::AlterIndexStatement {
3791            name,
3792            target: crate::ast::AlterIndexTarget::Rebuild { encoding },
3793        }))
3794    }
3795
3796    /// v6.7.2 — `ALTER TABLE <name> SET hot_tier_bytes = <n>`. The
3797    /// only `SET` form currently supported; future v6.7.x can add
3798    /// more SET subjects without changing the dispatch shape.
3799    /// v7.13.2 — mailrs round-6 S1: accepts comma-separated
3800    /// subactions. Single-subaction shape stays a 1-element vec.
3801    fn parse_alter_table_after_keyword(&mut self) -> Result<Statement, ParseError> {
3802        let table_name = self.expect_ident_like()?;
3803        let mut targets: Vec<crate::ast::AlterTableTarget> = Vec::new();
3804        loop {
3805            let subaction = self.parse_alter_table_subaction()?;
3806            // ADD COLUMN with inline REFERENCES emits both an
3807            // AddColumn and an AddForeignKey subaction; the
3808            // helper returns 1 or 2 items.
3809            targets.extend(subaction);
3810            if matches!(self.peek(), Token::Comma) {
3811                self.advance();
3812                continue;
3813            }
3814            break;
3815        }
3816        Ok(Statement::AlterTable(crate::ast::AlterTableStatement {
3817            name: table_name,
3818            targets,
3819        }))
3820    }
3821
3822    /// Parse one ALTER TABLE subaction. Returns a Vec because
3823    /// inline `REFERENCES` on `ADD COLUMN` produces both an
3824    /// AddColumn and an AddForeignKey entry (mailrs round-6 S3).
3825    fn parse_alter_table_subaction(
3826        &mut self,
3827    ) -> Result<Vec<crate::ast::AlterTableTarget>, ParseError> {
3828        match self.peek() {
3829            Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
3830                self.advance();
3831                let setting = self.expect_ident_like()?;
3832                if !setting.eq_ignore_ascii_case("hot_tier_bytes") {
3833                    return Err(self.err(alloc::format!(
3834                        "ALTER TABLE SET: unknown setting {setting:?}; supported: hot_tier_bytes"
3835                    )));
3836                }
3837                if !matches!(self.peek(), Token::Eq) {
3838                    return Err(self.err(alloc::format!(
3839                        "expected '=' after hot_tier_bytes, got {:?}",
3840                        self.peek()
3841                    )));
3842                }
3843                self.advance();
3844                let n = self.expect_u64_literal()?;
3845                Ok(alloc::vec![crate::ast::AlterTableTarget::SetHotTierBytes(n)])
3846            }
3847            Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
3848                self.advance();
3849                // v7.14.0 — ADD CONSTRAINT <name> { FOREIGN KEY |
3850                // PRIMARY KEY | UNIQUE | CHECK }. pg_dump emits
3851                // PRIMARY KEY this way; mysqldump emits both.
3852                // Peek-only dispatch (no advance) — `advance()`
3853                // destructively replaces consumed tokens with Eof,
3854                // so saved-pos restore would land on Eofs.
3855                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint"))
3856                {
3857                    // The next-but-one ident is the constraint
3858                    // name; the one after THAT is the kind.
3859                    let kind_pos = self.pos + 2;
3860                    let kind = self.tokens.get(kind_pos).cloned();
3861                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("foreign"))
3862                    {
3863                        let fk = self.parse_table_level_fk()?;
3864                        return Ok(alloc::vec![
3865                            crate::ast::AlterTableTarget::AddForeignKey(fk)
3866                        ]);
3867                    }
3868                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary"))
3869                    {
3870                        self.advance(); // CONSTRAINT
3871                        let _name = self.expect_ident_like()?;
3872                        self.advance(); // PRIMARY
3873                        self.expect_keyword_ident("key")?;
3874                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3875                        return Ok(alloc::vec![
3876                            crate::ast::AlterTableTarget::AddTableConstraint(
3877                                crate::ast::TableConstraint::PrimaryKey {
3878                                    name: None,
3879                                    columns: cols,
3880                                }
3881                            )
3882                        ]);
3883                    }
3884                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique"))
3885                    {
3886                        self.advance(); // CONSTRAINT
3887                        let _name = self.expect_ident_like()?;
3888                        // v7.22 (mailrs round-13 gap 6) — delegate so
3889                        // the optional `NULLS [NOT] DISTINCT` modifier
3890                        // parses here too (pg_dump emits the ALTER
3891                        // form; semantics enforced by the engine
3892                        // since v7.13).
3893                        let uc = self.parse_table_level_unique()?;
3894                        return Ok(alloc::vec![
3895                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
3896                        ]);
3897                    }
3898                    if matches!(&kind, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check"))
3899                    {
3900                        self.advance(); // CONSTRAINT
3901                        let _name = self.expect_ident_like()?;
3902                        self.advance(); // CHECK
3903                        if !matches!(self.peek(), Token::LParen) {
3904                            return Err(self.err(alloc::format!(
3905                                "expected '(' after CHECK, got {:?}", self.peek()
3906                            )));
3907                        }
3908                        self.advance();
3909                        let expr = self.parse_expr(0)?;
3910                        if matches!(self.peek(), Token::RParen) {
3911                            self.advance();
3912                        }
3913                        return Ok(alloc::vec![
3914                            crate::ast::AlterTableTarget::AddTableConstraint(
3915                                crate::ast::TableConstraint::Check { name: None, expr }
3916                            )
3917                        ]);
3918                    }
3919                    // Unknown kind — fall through to FK path which
3920                    // produces a descriptive parse error.
3921                }
3922                let is_fk = matches!(
3923                    self.peek(),
3924                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
3925                        || s.eq_ignore_ascii_case("foreign")
3926                );
3927                if is_fk {
3928                    let fk = self.parse_table_level_fk()?;
3929                    return Ok(alloc::vec![crate::ast::AlterTableTarget::AddForeignKey(fk)]);
3930                }
3931                // v7.14.0 — bare ADD PRIMARY KEY / UNIQUE / CHECK
3932                // (no CONSTRAINT prefix) — same dispatch.
3933                match self.peek().clone() {
3934                    Token::Ident(s) if s.eq_ignore_ascii_case("primary") => {
3935                        self.advance();
3936                        self.expect_keyword_ident("key")?;
3937                        let cols = self.parse_paren_ident_list("PRIMARY KEY")?;
3938                        return Ok(alloc::vec![
3939                            crate::ast::AlterTableTarget::AddTableConstraint(
3940                                crate::ast::TableConstraint::PrimaryKey {
3941                                    name: None,
3942                                    columns: cols,
3943                                }
3944                            )
3945                        ]);
3946                    }
3947                    Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
3948                        // v7.22 — delegate (NULLS [NOT] DISTINCT).
3949                        let uc = self.parse_table_level_unique()?;
3950                        return Ok(alloc::vec![
3951                            crate::ast::AlterTableTarget::AddTableConstraint(uc)
3952                        ]);
3953                    }
3954                    _ => {}
3955                }
3956                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
3957                    self.advance();
3958                }
3959                let mut if_not_exists = false;
3960                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
3961                    self.advance();
3962                    if !matches!(self.peek(), Token::Not) {
3963                        return Err(self.err(alloc::format!(
3964                            "expected NOT after IF in ALTER TABLE ADD COLUMN, got {:?}",
3965                            self.peek()
3966                        )));
3967                    }
3968                    self.advance();
3969                    if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("exists")) {
3970                        return Err(self.err(alloc::format!(
3971                            "expected EXISTS after IF NOT in ALTER TABLE ADD COLUMN, got {:?}",
3972                            self.peek()
3973                        )));
3974                    }
3975                    self.advance();
3976                    if_not_exists = true;
3977                }
3978                // v7.13.2 — mailrs round-6 S3: `ADD COLUMN col TYPE
3979                // REFERENCES other(col) [ON DELETE …]`. parse_column_def
3980                // returns ColumnDef + an optional inline FK.
3981                let (column, col_level_fk) = self.parse_column_def_with_fk()?;
3982                let col_name = column.name.clone();
3983                let mut out = alloc::vec![crate::ast::AlterTableTarget::AddColumn {
3984                    column,
3985                    if_not_exists,
3986                }];
3987                if let Some(mut fk) = col_level_fk {
3988                    if fk.columns.is_empty() {
3989                        fk.columns.push(col_name);
3990                    }
3991                    out.push(crate::ast::AlterTableTarget::AddForeignKey(fk));
3992                }
3993                Ok(out)
3994            }
3995            Token::Drop => {
3996                self.advance();
3997                // v7.13.3 — dispatch on the next token. mailrs round-7
3998                // S8 closed DROP COLUMN; round-6 S7 closed
3999                // DROP CONSTRAINT. Both share IF EXISTS / CASCADE /
4000                // RESTRICT modifiers.
4001                //   DROP CONSTRAINT [IF EXISTS] <name> [CASCADE|RESTRICT]
4002                //   DROP [COLUMN] [IF EXISTS] <col> [CASCADE|RESTRICT]
4003                let subject = match self.peek() {
4004                    Token::Ident(s) if s.eq_ignore_ascii_case("constraint") => {
4005                        self.advance();
4006                        "constraint"
4007                    }
4008                    Token::Ident(s) if s.eq_ignore_ascii_case("column") => {
4009                        self.advance();
4010                        "column"
4011                    }
4012                    // PG-canonical bare `DROP <col>` without COLUMN
4013                    // keyword is also valid; treat any other ident
4014                    // as the column name.
4015                    Token::Ident(_) | Token::QuotedIdent(_) => "column",
4016                    other => {
4017                        return Err(self.err(alloc::format!(
4018                            "expected COLUMN / CONSTRAINT after DROP in ALTER TABLE, got {other:?}"
4019                        )));
4020                    }
4021                };
4022                let mut if_exists = false;
4023                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if")) {
4024                    let n1 = self.tokens.get(self.pos + 1);
4025                    if matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")) {
4026                        self.advance();
4027                        self.advance();
4028                        if_exists = true;
4029                    }
4030                }
4031                let name = self.expect_ident_like()?;
4032                let mut cascade = false;
4033                if matches!(
4034                    self.peek(),
4035                    Token::Ident(s) if s.eq_ignore_ascii_case("cascade")
4036                        || s.eq_ignore_ascii_case("restrict")
4037                ) {
4038                    if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("cascade"))
4039                    {
4040                        cascade = true;
4041                    }
4042                    self.advance();
4043                }
4044                if subject == "constraint" {
4045                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropForeignKey {
4046                        name,
4047                        if_exists,
4048                    }])
4049                } else {
4050                    Ok(alloc::vec![crate::ast::AlterTableTarget::DropColumn {
4051                        column: name,
4052                        if_exists,
4053                        cascade,
4054                    }])
4055                }
4056            }
4057            Token::Ident(s) if s.eq_ignore_ascii_case("alter") => {
4058                self.advance();
4059                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4060                    self.advance();
4061                }
4062                let col_name = self.expect_ident_like()?;
4063                match self.peek() {
4064                    Token::Ident(s) if s.eq_ignore_ascii_case("type") => {
4065                        self.advance();
4066                    }
4067                    // v7.14.0 — pg_dump emits BIGSERIAL via
4068                    // `ALTER TABLE … ALTER COLUMN id SET DEFAULT
4069                    // nextval('seq')` (the sequence is created
4070                    // separately). SPG's BIGSERIAL already uses
4071                    // AUTO_INCREMENT; accept SET DEFAULT / DROP
4072                    // DEFAULT / SET NOT NULL / DROP NOT NULL as
4073                    // engine no-ops by consuming the tail.
4074                    Token::Ident(s) if s.eq_ignore_ascii_case("set") => {
4075                        // v7.22 (round-13 T2) — `SET DEFAULT
4076                        // nextval('…')` is how pg_dump spells a
4077                        // SERIAL column (plain integer in CREATE
4078                        // TABLE + this ALTER). It used to be
4079                        // swallowed as a no-op, which silently
4080                        // STRIPPED auto-increment from imported
4081                        // schemas — the first post-import INSERT
4082                        // without an explicit id then violated NOT
4083                        // NULL. Lower it to the auto-increment
4084                        // marker instead.
4085                        let is_default_nextval =
4086                            matches!(self.tokens.get(self.pos + 1), Some(Token::Default))
4087                                && matches!(
4088                                    self.tokens.get(self.pos + 2),
4089                                    Some(Token::Ident(f)) if f.eq_ignore_ascii_case("nextval")
4090                                );
4091                        // Capture the nextval target so the engine
4092                        // can guarantee the sequence exists.
4093                        let seq_name = if is_default_nextval {
4094                            self.scan_sequence_name_until_boundary()
4095                        } else {
4096                            self.consume_until_statement_boundary();
4097                            None
4098                        };
4099                        if is_default_nextval {
4100                            return Ok(alloc::vec![
4101                                crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4102                                    column: col_name,
4103                                    seq_name,
4104                                }
4105                            ]);
4106                        }
4107                        // Other SET DEFAULT … / SET NOT NULL forms
4108                        // stay engine no-ops (real defaults arrive
4109                        // inline in CREATE TABLE in every dump;
4110                        // nullability change would need a row scan
4111                        // — deferred).
4112                        return Ok(Vec::new());
4113                    }
4114                    Token::Ident(s) if s.eq_ignore_ascii_case("drop") => {
4115                        // ALTER COLUMN col DROP DEFAULT / DROP NOT NULL.
4116                        self.consume_until_statement_boundary();
4117                        return Ok(Vec::new());
4118                    }
4119                    Token::Ident(s) if s.eq_ignore_ascii_case("add") => {
4120                        // v7.22 (round-13 T2) — `ALTER COLUMN c ADD
4121                        // GENERATED { ALWAYS | BY DEFAULT } AS
4122                        // IDENTITY ( … )`: pg_dump's spelling for
4123                        // identity columns. Same auto-increment
4124                        // lowering as the nextval default; the
4125                        // sequence options inside the parens are
4126                        // no-ops under SPG's max+1 semantics.
4127                        let is_generated = matches!(
4128                            self.tokens.get(self.pos + 1),
4129                            Some(Token::Ident(g)) if g.eq_ignore_ascii_case("generated")
4130                        );
4131                        if !is_generated {
4132                            return Err(self.err(alloc::format!(
4133                                "expected GENERATED after ALTER COLUMN {col_name} ADD, got {:?}",
4134                                self.tokens.get(self.pos + 1)
4135                            )));
4136                        }
4137                        let seq_name = self.scan_sequence_name_until_boundary();
4138                        return Ok(alloc::vec![
4139                            crate::ast::AlterTableTarget::SetColumnAutoIncrement {
4140                                column: col_name,
4141                                seq_name,
4142                            }
4143                        ]);
4144                    }
4145                    other => {
4146                        return Err(self.err(alloc::format!(
4147                            "expected TYPE / SET / DROP / ADD after ALTER COLUMN <name>, got {other:?}"
4148                        )));
4149                    }
4150                }
4151                let new_type = self.parse_column_type_name()?;
4152                let using = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using"))
4153                {
4154                    self.advance();
4155                    Some(self.parse_expr(0)?)
4156                } else {
4157                    None
4158                };
4159                Ok(alloc::vec![crate::ast::AlterTableTarget::AlterColumnType {
4160                    column: col_name,
4161                    new_type,
4162                    using,
4163                }])
4164            }
4165            // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO new`.
4166            // PG also supports `RENAME TO new_table` for table-name
4167            // rename; that surface is deferred (pg_dump never emits
4168            // it). If the first post-RENAME ident is `TO`, the user
4169            // is asking for table rename — error with a clear
4170            // message rather than misparsing `TO` as a column name.
4171            Token::Ident(s) if s.eq_ignore_ascii_case("rename") => {
4172                self.advance();
4173                // v7.16.2 — `ALTER TABLE t RENAME TO new_table`
4174                // table-name rename (mailrs round-10 A.5 — used
4175                // by migrate-042's `RENAME TO email_contacts`).
4176                // `TO` lexes as Token::To.
4177                if matches!(self.peek(), Token::To)
4178                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("to"))
4179                {
4180                    self.advance();
4181                    let new = self.expect_ident_like()?;
4182                    return Ok(alloc::vec![crate::ast::AlterTableTarget::RenameTable {
4183                        new,
4184                    }]);
4185                }
4186                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("column")) {
4187                    self.advance();
4188                }
4189                let old = self.expect_ident_like()?;
4190                // `TO` is a reserved keyword token; accept both
4191                // Token::To and Token::Ident("to") for consistency.
4192                if matches!(self.peek(), Token::To) {
4193                    self.advance();
4194                } else {
4195                    self.expect_keyword_ident("to")?;
4196                }
4197                let new = self.expect_ident_like()?;
4198                Ok(alloc::vec![crate::ast::AlterTableTarget::RenameColumn {
4199                    old,
4200                    new,
4201                }])
4202            }
4203            // v7.16.1 — `ALTER TABLE t { ENABLE | DISABLE } TRIGGER
4204            // { ALL | <name> }`. pg_dump --disable-triggers wraps
4205            // every data block with these. Real disable semantics —
4206            // not no-op — because reload correctness assumes the
4207            // triggers don't fire (rows already carry their
4208            // computed values from prod).
4209            Token::Ident(s)
4210                if s.eq_ignore_ascii_case("enable") || s.eq_ignore_ascii_case("disable") =>
4211            {
4212                let enabled = s.eq_ignore_ascii_case("enable");
4213                self.advance();
4214                // PG also accepts ENABLE/DISABLE { REPLICA | ALWAYS }
4215                // TRIGGER … and ENABLE/DISABLE RULE / ROW LEVEL
4216                // SECURITY. v7.16.1 only matches TRIGGER (mailrs's
4217                // pg_dump output) — anything else falls through to
4218                // the catch-all error below.
4219                // v7.22 (round-13 T3) — mysqldump wraps every data
4220                // section in `/*!40000 ALTER TABLE t DISABLE KEYS */`
4221                // + ENABLE KEYS (a MyISAM index-rebuild hint). SPG
4222                // maintains indexes incrementally — engine no-op.
4223                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("keys")) {
4224                    self.advance();
4225                    return Ok(Vec::new());
4226                }
4227                if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("trigger")) {
4228                    return Err(self.err(alloc::format!(
4229                        "expected TRIGGER after {}, got {:?}",
4230                        if enabled { "ENABLE" } else { "DISABLE" },
4231                        self.peek()
4232                    )));
4233                }
4234                self.advance();
4235                // `ALL` lexes as Token::All (reserved); also
4236                // accept Token::Ident("all") for symmetry.
4237                let which = if matches!(self.peek(), Token::All)
4238                    || matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("all"))
4239                {
4240                    self.advance();
4241                    crate::ast::TriggerSelector::All
4242                } else {
4243                    let name = self.expect_ident_like()?;
4244                    crate::ast::TriggerSelector::Named(name)
4245                };
4246                Ok(alloc::vec![crate::ast::AlterTableTarget::SetTriggerEnabled {
4247                    which,
4248                    enabled,
4249                }])
4250            }
4251            other => Err(self.err(alloc::format!(
4252                "expected SET / ADD / DROP / ALTER / RENAME / ENABLE / DISABLE in ALTER TABLE, got {other:?}"
4253            ))),
4254        }
4255    }
4256
4257    /// v7.16.2 — peek for `information_schema.<tbl>` /
4258    /// `pg_catalog.<tbl>` triples and, if matched, consume all
4259    /// three tokens + return a synthetic table name the engine's
4260    /// SELECT path recognises as a virtual view. Returns `None`
4261    /// when the head doesn't look like a meta-qualified name.
4262    /// Used by `parse_table_ref` to bypass the
4263    /// `expect_ident_like` schema-strip for these specific PG
4264    /// meta schemas (mailrs round-10 A.3).
4265    fn try_peek_meta_qualified(&mut self) -> Option<String> {
4266        // Extract the schema name. Must be a plain ident token.
4267        let schema = match self.tokens.get(self.pos) {
4268            Some(Token::Ident(s) | Token::QuotedIdent(s)) => s.clone(),
4269            _ => return None,
4270        };
4271        // Dot.
4272        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4273            return None;
4274        }
4275        // The table-side ident may lex as a reserved keyword
4276        // (e.g. `Token::Tables`). Tolerate the common ones via a
4277        // helper that reads the trailing token's underlying name.
4278        let tbl = match self.tokens.get(self.pos + 2)? {
4279            Token::Ident(t) | Token::QuotedIdent(t) => t.clone(),
4280            Token::Tables => "tables".to_string(),
4281            // Other PG meta table names that may collide with
4282            // reserved keywords land here as needed.
4283            _ => return None,
4284        };
4285        // Strip the `pg_` prefix from `pg_catalog.pg_class`-style
4286        // names so the synthetic name doesn't double-prefix
4287        // (`__spg_pg_class`, not `__spg_pg_pg_class`).
4288        let (prefix, normalised) = if schema.eq_ignore_ascii_case("information_schema") {
4289            ("__spg_info_", tbl.to_ascii_lowercase())
4290        } else if schema.eq_ignore_ascii_case("pg_catalog") {
4291            let bare = tbl
4292                .to_ascii_lowercase()
4293                .strip_prefix("pg_")
4294                .map(alloc::string::String::from)
4295                .unwrap_or_else(|| tbl.to_ascii_lowercase());
4296            ("__spg_pg_", bare)
4297        } else if schema.eq_ignore_ascii_case("mysql") {
4298            // v7.17.0 Phase 3.P0-65 — MySQL system schema
4299            // (`mysql.user`, `mysql.db`). Same synthetic-name
4300            // shape as pg_catalog.
4301            ("__spg_mysql_", tbl.to_ascii_lowercase())
4302        } else {
4303            return None;
4304        };
4305        self.advance(); // schema
4306        self.advance(); // dot
4307        self.advance(); // tbl
4308        Some(alloc::format!("{prefix}{normalised}"))
4309    }
4310
4311    /// Unqualified PG meta-table names (`FROM pg_extension`, `FROM
4312    /// pg_class`) resolve the same way: PG puts `pg_catalog` at the
4313    /// implicit front of every search_path, so a bare reference to a
4314    /// known catalog table always means the catalog table. Only the
4315    /// names the engine actually synthesises are recognised — any
4316    /// other `pg_*` ident stays a user table (mailrs embed round-12).
4317    fn try_peek_meta_bare(&mut self) -> Option<String> {
4318        const PG_META_TABLES: &[&str] = &[
4319            "pg_attribute",
4320            "pg_class",
4321            "pg_constraint",
4322            "pg_database",
4323            "pg_extension",
4324            "pg_index",
4325            "pg_indexes",
4326            "pg_matviews",
4327            "pg_namespace",
4328            "pg_proc",
4329            "pg_roles",
4330            "pg_settings",
4331            "pg_type",
4332            "pg_user",
4333            "pg_views",
4334        ];
4335        let name = match self.tokens.get(self.pos) {
4336            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4337            _ => return None,
4338        };
4339        // A following dot means this ident is a schema qualifier,
4340        // not a table name — let the qualified path handle it.
4341        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4342            return None;
4343        }
4344        if !PG_META_TABLES.contains(&name.as_str()) {
4345            return None;
4346        }
4347        self.advance();
4348        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4349        Some(alloc::format!("__spg_pg_{bare}"))
4350    }
4351
4352    /// Consume a bare ident if its lowercase matches `kw`, else err.
4353    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4354        match self.advance() {
4355            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4356            other => Err(ParseError {
4357                message: format!("expected {kw:?}, got {other:?}"),
4358                token_pos: self.pos.saturating_sub(1),
4359            }),
4360        }
4361    }
4362
4363    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4364    /// literal (`'foo'`) — same shape used by CREATE USER for the
4365    /// username slot.
4366    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4367        match self.advance() {
4368            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4369            other => Err(ParseError {
4370                message: format!("expected identifier or string, got {other:?}"),
4371                token_pos: self.pos.saturating_sub(1),
4372            }),
4373        }
4374    }
4375
4376    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4377        match self.advance() {
4378            Token::String(s) => Ok(s),
4379            other => Err(ParseError {
4380                message: format!("expected quoted string, got {other:?}"),
4381                token_pos: self.pos.saturating_sub(1),
4382            }),
4383        }
4384    }
4385
4386    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4387        // Caller dispatches on Token::Select; the inner helper handles
4388        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4389        // get a fresh bare-select parse and may not have their own ORDER
4390        // BY / LIMIT.
4391        let mut head = self.parse_bare_select()?;
4392        while matches!(self.peek(), Token::Union) {
4393            self.advance();
4394            let kind = if matches!(self.peek(), Token::All) {
4395                self.advance();
4396                UnionKind::All
4397            } else {
4398                UnionKind::Distinct
4399            };
4400            let peer = self.parse_bare_select()?;
4401            head.unions.push((kind, peer));
4402        }
4403        head.order_by = if matches!(self.peek(), Token::Order) {
4404            self.advance();
4405            if !matches!(self.peek(), Token::By) {
4406                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4407            }
4408            self.advance();
4409            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4410            // `<expr> [ASC|DESC]` items.
4411            let mut keys = Vec::new();
4412            loop {
4413                let expr = self.parse_expr(0)?;
4414                let desc = if matches!(self.peek(), Token::Desc) {
4415                    self.advance();
4416                    true
4417                } else if matches!(self.peek(), Token::Asc) {
4418                    self.advance();
4419                    false
4420                } else {
4421                    false
4422                };
4423                keys.push(OrderBy { expr, desc });
4424                if matches!(self.peek(), Token::Comma) {
4425                    self.advance();
4426                } else {
4427                    break;
4428                }
4429            }
4430            keys
4431        } else {
4432            Vec::new()
4433        };
4434        head.limit = if matches!(self.peek(), Token::Limit) {
4435            self.advance();
4436            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4437            // PG synonyms for "no limit". Treat both as None
4438            // (no head.limit set) so the engine's existing
4439            // unlimited-result path takes over. Reject was the
4440            // pre-5.1 behaviour and broke pg_dump-flavoured
4441            // tooling that occasionally emits LIMIT NULL.
4442            if self.consume_limit_unbounded_sentinel() {
4443                None
4444            } else {
4445                Some(self.parse_limit_expr("LIMIT")?)
4446            }
4447        } else {
4448            None
4449        };
4450        head.offset = if matches!(self.peek(), Token::Offset) {
4451            self.advance();
4452            // PG also accepts an optional `ROW` / `ROWS` trailer
4453            // after the offset value (`OFFSET 10 ROWS`). The
4454            // FETCH-FIRST branch below relies on the same.
4455            let off = self.parse_limit_expr("OFFSET")?;
4456            self.consume_optional_rows_keyword();
4457            Some(off)
4458        } else {
4459            None
4460        };
4461        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4462        // the SQL-standard alias for LIMIT. PG accepts both
4463        // spellings interchangeably; pg_dump emits FETCH FIRST in
4464        // newer versions. We map it onto `head.limit` so the
4465        // engine path is unified.
4466        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4467        {
4468            self.advance(); // FETCH
4469            // `FIRST` or `NEXT` (both legal per SQL standard).
4470            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4471                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4472            {
4473                self.advance();
4474            }
4475            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4476            // implicit 1 — but we always consume one if present).
4477            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4478                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4479            {
4480                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4481                crate::ast::LimitExpr::Literal(1)
4482            } else {
4483                self.parse_limit_expr("FETCH FIRST")?
4484            };
4485            // Eat `ROW` / `ROWS` if not already consumed above.
4486            self.consume_optional_rows_keyword();
4487            // Optional `ONLY` (the spec form) — or the SQL:2008
4488            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4489            // now honours WITH TIES by extending past the LIMIT
4490            // truncation point through every row that shares the
4491            // last-kept row's ORDER BY key.
4492            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4493                if s.eq_ignore_ascii_case("only"))
4494            {
4495                self.advance();
4496            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4497                if s.eq_ignore_ascii_case("with"))
4498            {
4499                self.advance(); // WITH
4500                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4501                    if s.eq_ignore_ascii_case("ties"))
4502                {
4503                    self.advance();
4504                    head.limit_with_ties = true;
4505                }
4506            }
4507            head.limit = Some(count);
4508        }
4509        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4510        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4511        //       [ OF table_name [, …] ]
4512        //       [ NOWAIT | SKIP LOCKED ]
4513        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4514        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4515        // SELECT already returns a consistent snapshot — so these
4516        // are accept-and-discard: the parser absorbs them so
4517        // mailrs / Rails / Django code paths that emit `SELECT
4518        // … FOR UPDATE` for advisory pessimistic locking load
4519        // without a parser error. The on-disk locking model is
4520        // unchanged; callers that rely on FOR UPDATE for read-
4521        // through-write ordering still get the right answer
4522        // because SPG serialises writes anyway.
4523        self.consume_optional_for_lock_clauses();
4524        Ok(Statement::Select(head))
4525    }
4526
4527    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4528    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4529    /// LOCKED ]` trailers. Each clause is fully accepted and
4530    /// discarded — SPG's single-writer model already satisfies the
4531    /// callers' implicit ordering requirement. Stops at the first
4532    /// token that isn't `FOR`.
4533    fn consume_optional_for_lock_clauses(&mut self) {
4534        while matches!(self.peek(), Token::For) {
4535            self.advance(); // FOR
4536            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4537            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4538            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4539                if s.eq_ignore_ascii_case("no"))
4540            {
4541                self.advance(); // NO
4542                // The next ident should be KEY but be generous;
4543                // anything followed by UPDATE/SHARE is accepted.
4544                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4545                    if s.eq_ignore_ascii_case("key"))
4546                {
4547                    self.advance(); // KEY
4548                }
4549            }
4550            // `KEY` prefix (PG `FOR KEY SHARE`).
4551            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4552                if s.eq_ignore_ascii_case("key"))
4553            {
4554                self.advance(); // KEY
4555            }
4556            // Lock-strength keyword: UPDATE / SHARE. Required, but
4557            // we're lenient — an unexpected token here just bails
4558            // (we already consumed FOR; caller's downstream
4559            // dispatch will error if anything actually depends on
4560            // the trailing tokens).
4561            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4562                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4563            {
4564                self.advance();
4565            } else {
4566                // FOR by itself (or `FOR KEY` with nothing after) —
4567                // give up on the lock-clause path. We've already
4568                // advanced past FOR; further attempts to parse
4569                // here would clobber state.
4570                return;
4571            }
4572            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4573            // joining and locking only a subset of tables.
4574            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4575                if s.eq_ignore_ascii_case("of"))
4576            {
4577                self.advance(); // OF
4578                #[allow(clippy::while_let_loop)]
4579                loop {
4580                    match self.peek() {
4581                        Token::Ident(_) | Token::QuotedIdent(_) => {
4582                            self.advance();
4583                            // Optional schema-qualified `schema.table`.
4584                            if matches!(self.peek(), Token::Dot) {
4585                                self.advance();
4586                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4587                                    self.advance();
4588                                }
4589                            }
4590                        }
4591                        _ => break,
4592                    }
4593                    if matches!(self.peek(), Token::Comma) {
4594                        self.advance();
4595                    } else {
4596                        break;
4597                    }
4598                }
4599            }
4600            // Optional `NOWAIT` | `SKIP LOCKED`.
4601            match self.peek().clone() {
4602                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4603                    self.advance();
4604                }
4605                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4606                    self.advance(); // SKIP
4607                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4608                        if s.eq_ignore_ascii_case("locked"))
4609                    {
4610                        self.advance(); // LOCKED
4611                    }
4612                }
4613                _ => {}
4614            }
4615            // Loop: PG allows multiple FOR clauses chained.
4616        }
4617    }
4618
4619    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4620    /// Bind value gets resolved during prepared-statement Execute;
4621    /// the Pratt expression parser would over-accept here (e.g.
4622    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4623    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4624    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4625    /// when one was consumed; caller skips the regular
4626    /// limit-value parse and leaves `head.limit` at None.
4627    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4628        if matches!(self.peek(), Token::Null) {
4629            self.advance();
4630            return true;
4631        }
4632        if matches!(self.peek(), Token::All) {
4633            self.advance();
4634            return true;
4635        }
4636        false
4637    }
4638
4639    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4640    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4641    /// SQL-standard shape. No-op when missing.
4642    fn consume_optional_rows_keyword(&mut self) {
4643        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4644            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4645        {
4646            self.advance();
4647        }
4648    }
4649
4650    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4651        match self.advance() {
4652            Token::Integer(n) if n >= 0 => u32::try_from(n)
4653                .map(crate::ast::LimitExpr::Literal)
4654                .map_err(|_| ParseError {
4655                    message: alloc::format!("{label} value too large: {n}"),
4656                    token_pos: self.pos.saturating_sub(1),
4657                }),
4658            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4659            other => Err(ParseError {
4660                message: alloc::format!(
4661                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4662                ),
4663                token_pos: self.pos.saturating_sub(1),
4664            }),
4665        }
4666    }
4667
4668    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4669    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4670    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4671    /// `parse_select_stmt` is responsible for filling those in.
4672    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4673        if !matches!(self.peek(), Token::Select) {
4674            return Err(self.err(format!(
4675                "expected SELECT to start a query block, got {:?}",
4676                self.peek()
4677            )));
4678        }
4679        self.advance();
4680        let distinct = if matches!(self.peek(), Token::Distinct) {
4681            self.advance();
4682            true
4683        } else {
4684            false
4685        };
4686        let items = self.parse_select_list()?;
4687        let from = if matches!(self.peek(), Token::From) {
4688            self.advance();
4689            Some(self.parse_from_clause()?)
4690        } else {
4691            None
4692        };
4693        let where_ = if matches!(self.peek(), Token::Where) {
4694            self.advance();
4695            Some(self.parse_expr(0)?)
4696        } else {
4697            None
4698        };
4699        let mut group_by_all = false;
4700        let group_by = if matches!(self.peek(), Token::Group) {
4701            self.advance();
4702            if !matches!(self.peek(), Token::By) {
4703                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4704            }
4705            self.advance();
4706            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4707            // every non-aggregate SELECT-list item later.
4708            if matches!(self.peek(), Token::All) {
4709                self.advance();
4710                group_by_all = true;
4711                None
4712            } else {
4713                let mut groups = Vec::new();
4714                loop {
4715                    groups.push(self.parse_expr(0)?);
4716                    if matches!(self.peek(), Token::Comma) {
4717                        self.advance();
4718                    } else {
4719                        break;
4720                    }
4721                }
4722                Some(groups)
4723            }
4724        } else {
4725            None
4726        };
4727        let having = if matches!(self.peek(), Token::Having) {
4728            self.advance();
4729            Some(self.parse_expr(0)?)
4730        } else {
4731            None
4732        };
4733        Ok(SelectStatement {
4734            ctes: Vec::new(),
4735            distinct,
4736            items,
4737            from,
4738            where_,
4739            group_by,
4740            group_by_all,
4741            having,
4742            unions: Vec::new(),
4743            order_by: Vec::new(),
4744            limit: None,
4745            offset: None,
4746            limit_with_ties: false,
4747        })
4748    }
4749
4750    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4751        // Caller already consumed CREATE; we're sitting on TABLE.
4752        debug_assert!(matches!(self.peek(), Token::Table));
4753        self.advance();
4754        let if_not_exists = self.consume_if_not_exists();
4755        let name = self.expect_ident_like()?;
4756        if !matches!(self.peek(), Token::LParen) {
4757            return Err(self.err(format!(
4758                "expected '(' after table name, got {:?}",
4759                self.peek()
4760            )));
4761        }
4762        self.advance();
4763        let mut columns = Vec::new();
4764        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
4765        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
4766        loop {
4767            // v7.6.0 / v7.9.18 — distinguish table-level constraint
4768            // clauses from column definitions. Constraints start
4769            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
4770            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
4771            // a column.
4772            if self.peek_table_level_pk_start() {
4773                table_constraints.push(self.parse_table_level_primary_key()?);
4774            } else if self.peek_table_level_unique_start() {
4775                table_constraints.push(self.parse_table_level_unique()?);
4776            } else if self.peek_table_level_check_start() {
4777                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
4778                table_constraints.push(self.parse_table_level_check()?);
4779            } else if self.peek_mysql_inline_key_start() {
4780                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
4781                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
4782                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
4783                // inside the column list. Skip name + paren list;
4784                // for UNIQUE KEY, register as a UC.
4785                if let Some(uc) = self.parse_mysql_inline_key()? {
4786                    table_constraints.push(uc);
4787                }
4788            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
4789                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
4790                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
4791                // CHECK is named, and the named-CONSTRAINT arm used
4792                // to accept FOREIGN KEY only. The name is accepted
4793                // and discarded — same handling as every other SPG
4794                // constraint name.
4795                self.advance(); // CONSTRAINT
4796                let _name = self.expect_ident_like()?;
4797                table_constraints.push(match kind {
4798                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
4799                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
4800                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
4801                });
4802            } else if self.peek_constraint_or_fk_start() {
4803                foreign_keys.push(self.parse_table_level_fk()?);
4804            } else {
4805                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
4806                // v7.13.0 — fold inline UNIQUE / CHECK column
4807                // constraints into table-level entries so the
4808                // engine path stays uniform.
4809                if col.is_unique {
4810                    table_constraints.push(crate::ast::TableConstraint::Unique {
4811                        name: None,
4812                        columns: alloc::vec![col.name.clone()],
4813                        nulls_not_distinct: false,
4814                    });
4815                }
4816                if let Some(check_expr) = col.check.clone() {
4817                    table_constraints.push(crate::ast::TableConstraint::Check {
4818                        name: None,
4819                        expr: check_expr,
4820                    });
4821                }
4822                columns.push(col);
4823                if let Some(fk) = col_level_fk {
4824                    foreign_keys.push(fk);
4825                }
4826            }
4827            match self.peek() {
4828                Token::Comma => {
4829                    self.advance();
4830                }
4831                Token::RParen => {
4832                    self.advance();
4833                    break;
4834                }
4835                other => {
4836                    return Err(
4837                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
4838                    );
4839                }
4840            }
4841        }
4842        if columns.is_empty() {
4843            return Err(self.err("CREATE TABLE requires at least one column".into()));
4844        }
4845        // v7.14.0 — consume MySQL/MariaDB table options after the
4846        // closing `)`. mysqldump emits things like
4847        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
4848        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
4849        // SPG accepts all forms as no-ops (each option is
4850        // `<ident> [=] <ident-or-string>` separated by whitespace).
4851        self.consume_mysql_table_options();
4852        Ok(Statement::CreateTable(CreateTableStatement {
4853            name,
4854            columns,
4855            if_not_exists,
4856            foreign_keys,
4857            table_constraints,
4858        }))
4859    }
4860
4861    /// v7.14.0 — true when the next tokens look like an inline
4862    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
4863    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
4864    /// — each followed by an optional name + `(...)`. Critical:
4865    /// a column NAMED `key` / `index` (PG accepts as ident) must
4866    /// NOT be mistaken for the KEY constraint shape. We disambig
4867    /// by requiring the keyword to be followed by either `(` or
4868    /// `<ident> (`.
4869    fn peek_mysql_inline_key_start(&self) -> bool {
4870        let cur = self.peek();
4871        // Shapes:
4872        //   KEY (cols)
4873        //   KEY name (cols)
4874        //   INDEX (cols)
4875        //   INDEX name (cols)
4876        //   UNIQUE KEY [name] (cols)
4877        //   UNIQUE INDEX [name] (cols)
4878        //   FULLTEXT [KEY|INDEX] [name] (cols)
4879        //   SPATIAL [KEY|INDEX] [name] (cols)
4880        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
4881            // tokens at skip = the position AFTER the index-form
4882            // keywords (KEY/INDEX) have been consumed.
4883            match self.tokens.get(skip) {
4884                Some(Token::LParen) => true,
4885                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
4886                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
4887                }
4888                _ => false,
4889            }
4890        };
4891        // `INDEX` lexes as Token::Index (reserved), not as
4892        // Token::Ident("index"). Both shapes count as a KEY/INDEX
4893        // start; the peek helper below handles either.
4894        let is_key_or_index_tok = |t: &Token| -> bool {
4895            matches!(t, Token::Index)
4896                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
4897        };
4898        match cur {
4899            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
4900            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4901                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
4902            }
4903            Token::Ident(s)
4904                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
4905            {
4906                let nxt = self.tokens.get(self.pos + 1);
4907                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
4908                    self.pos + 2
4909                } else {
4910                    self.pos + 1
4911                };
4912                after_keyword_followed_by_paren_or_ident_paren(after_after)
4913            }
4914            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4915                let nxt = self.tokens.get(self.pos + 1);
4916                if !nxt.is_some_and(is_key_or_index_tok) {
4917                    return false;
4918                }
4919                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
4920            }
4921            _ => false,
4922        }
4923    }
4924
4925    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
4926    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
4927    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
4928    /// returns Some(TableConstraint::Index) so the engine builds
4929    /// a real BTree index on the leading column (mysqldump
4930    /// `KEY idx_posts_author (author_id)` shape).
4931    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
4932    /// (the storage layer has no matching AM).
4933    fn parse_mysql_inline_key(
4934        &mut self,
4935    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
4936        // Detect UNIQUE prefix.
4937        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
4938        {
4939            self.advance();
4940            true
4941        } else {
4942            false
4943        };
4944        // Consume FULLTEXT / SPATIAL prefix and record which one
4945        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
4946        // dedicated TableConstraint variant so the engine can
4947        // build a tsvector-GIN; SPATIAL still has no matching
4948        // AM, so it falls back to accept-as-no-op.
4949        let mut is_fulltext = false;
4950        let mut is_spatial = false;
4951        if let Token::Ident(s) = self.peek().clone() {
4952            if s.eq_ignore_ascii_case("fulltext") {
4953                self.advance();
4954                is_fulltext = true;
4955            } else if s.eq_ignore_ascii_case("spatial") {
4956                self.advance();
4957                is_spatial = true;
4958            }
4959        }
4960        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
4961        // (reserved); accept either token shape.
4962        match self.peek() {
4963            Token::Index => {
4964                self.advance();
4965            }
4966            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4967                self.advance();
4968            }
4969            other => {
4970                return Err(self.err(alloc::format!(
4971                    "expected KEY/INDEX in inline index declaration, got {other:?}"
4972                )));
4973            }
4974        }
4975        // Optional index name (an ident before the `(`).
4976        // v7.15.0 — capture the name when present so the engine
4977        // builds the secondary index under the user's chosen
4978        // name (matches mysqldump's `KEY idx_x (col)` shape).
4979        let mut idx_name: Option<String> = None;
4980        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
4981            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
4982        {
4983            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
4984                idx_name = Some(s);
4985            }
4986        }
4987        // Optional `USING BTREE` / `USING HASH` (MySQL).
4988        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
4989            self.advance();
4990            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4991                self.advance();
4992            }
4993        }
4994        // Required column list `(col [, col]*)`.
4995        if !matches!(self.peek(), Token::LParen) {
4996            return Err(self.err(alloc::format!(
4997                "expected '(' in inline KEY/INDEX, got {:?}",
4998                self.peek()
4999            )));
5000        }
5001        self.advance();
5002        let mut cols: Vec<String> = Vec::new();
5003        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5004            self.advance();
5005            cols.push(s);
5006            // Skip optional `(length)` per-column prefix.
5007            if matches!(self.peek(), Token::LParen) {
5008                let mut depth = 1usize;
5009                self.advance();
5010                while depth > 0 {
5011                    match self.peek() {
5012                        Token::LParen => depth += 1,
5013                        Token::RParen => depth -= 1,
5014                        Token::Eof => break,
5015                        _ => {}
5016                    }
5017                    self.advance();
5018                }
5019            }
5020            // Skip optional ASC / DESC.
5021            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5022                || matches!(self.peek(), Token::Asc | Token::Desc)
5023            {
5024                self.advance();
5025            }
5026            if matches!(self.peek(), Token::Comma) {
5027                self.advance();
5028                continue;
5029            }
5030            break;
5031        }
5032        if matches!(self.peek(), Token::RParen) {
5033            self.advance();
5034        }
5035        // Trailing options on the inline index — comment / etc.
5036        // Skip until comma or `)`.
5037        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5038            self.advance();
5039        }
5040        if cols.is_empty() {
5041            return Ok(None);
5042        }
5043        if is_unique {
5044            // Carry the captured idx_name on UNIQUE too so future
5045            // engine work can name the underlying BTree
5046            // accordingly; today the unique-constraint installer
5047            // synthesises the name itself, but Display round-trip
5048            // benefits from preserving it.
5049            Ok(Some(crate::ast::TableConstraint::Unique {
5050                name: idx_name,
5051                columns: cols,
5052                nulls_not_distinct: false,
5053            }))
5054        } else if is_fulltext {
5055            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5056            // routes through `TableConstraint::FulltextIndex`;
5057            // the engine builds a tsvector-GIN over each named
5058            // column so MATCH AGAINST gets a real inverted
5059            // index instead of a silently-dropped declaration.
5060            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5061                name: idx_name,
5062                columns: cols,
5063            }))
5064        } else if is_spatial {
5065            // SPG has no native SPATIAL AM. Accept-as-no-op
5066            // (declaration is parsed, but no index is built).
5067            Ok(None)
5068        } else {
5069            // v7.15.0 — plain KEY / INDEX builds a real BTree
5070            // secondary index.
5071            Ok(Some(crate::ast::TableConstraint::Index {
5072                name: idx_name,
5073                columns: cols,
5074            }))
5075        }
5076    }
5077
5078    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5079    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5080    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5081    /// (in any order, separated by whitespace).
5082    fn consume_mysql_table_options(&mut self) {
5083        loop {
5084            // Heuristic: a table option is an ident (or `DEFAULT`
5085            // reserved keyword) followed by `=` and an
5086            // ident / string / integer.
5087            let name_lc = match self.peek().clone() {
5088                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5089                Token::Default => alloc::string::String::from("default"),
5090                _ => break,
5091            };
5092            let known = matches!(
5093                name_lc.as_str(),
5094                "engine"
5095                    | "default"
5096                    | "charset"
5097                    | "collate"
5098                    | "auto_increment"
5099                    | "row_format"
5100                    | "comment"
5101                    | "pack_keys"
5102                    | "stats_persistent"
5103                    | "stats_auto_recalc"
5104                    | "stats_sample_pages"
5105                    | "key_block_size"
5106                    | "tablespace"
5107                    | "min_rows"
5108                    | "max_rows"
5109                    | "checksum"
5110                    | "delay_key_write"
5111                    | "insert_method"
5112                    | "data"
5113                    | "index"
5114                    | "encryption"
5115                    | "compression"
5116            );
5117            if !known {
5118                break;
5119            }
5120            self.advance(); // option name
5121            // `DEFAULT` optional prefix is followed by `CHARSET` /
5122            // `COLLATE`; consume the next ident too.
5123            if name_lc == "default" {
5124                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5125                    self.advance();
5126                }
5127            }
5128            if matches!(self.peek(), Token::Eq) {
5129                self.advance();
5130            }
5131            match self.peek() {
5132                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5133                    self.advance();
5134                }
5135                _ => {}
5136            }
5137        }
5138    }
5139
5140    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5141    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5142    /// sure (otherwise a column literally named `primary` would
5143    /// be mistaken).
5144    fn peek_table_level_pk_start(&self) -> bool {
5145        let cur = self.peek();
5146        let nxt = self.tokens.get(self.pos + 1);
5147        let nxt2 = self.tokens.get(self.pos + 2);
5148        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5149        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5150        let is_lparen = matches!(nxt2, Some(Token::LParen));
5151        is_primary && is_key && is_lparen
5152    }
5153
5154    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5155    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5156    /// (mailrs round-5 G10).
5157    fn peek_table_level_unique_start(&self) -> bool {
5158        let cur = self.peek();
5159        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5160        if !is_unique {
5161            return false;
5162        }
5163        let n1 = self.tokens.get(self.pos + 1);
5164        // Plain `UNIQUE (…)`.
5165        if matches!(n1, Some(Token::LParen)) {
5166            return true;
5167        }
5168        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5169        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5170        if !is_nulls {
5171            return false;
5172        }
5173        let n2 = self.tokens.get(self.pos + 2);
5174        let n3 = self.tokens.get(self.pos + 3);
5175        let n4 = self.tokens.get(self.pos + 4);
5176        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5177        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5178            return true;
5179        }
5180        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5181        if matches!(n2, Some(Token::Not))
5182            && matches!(n3, Some(Token::Distinct))
5183            && matches!(n4, Some(Token::LParen))
5184        {
5185            return true;
5186        }
5187        false
5188    }
5189
5190    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5191        self.advance(); // PRIMARY
5192        self.advance(); // KEY
5193        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5194        Ok(crate::ast::TableConstraint::PrimaryKey {
5195            name: None,
5196            columns,
5197        })
5198    }
5199
5200    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5201        self.advance(); // UNIQUE
5202        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5203        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5204        // is `NULLS DISTINCT` per the SQL standard.
5205        let mut nulls_not_distinct = false;
5206        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5207            let n1 = self.tokens.get(self.pos + 1);
5208            let n2 = self.tokens.get(self.pos + 2);
5209            let is_not = matches!(n1, Some(Token::Not));
5210            let is_distinct = matches!(n2, Some(Token::Distinct));
5211            if is_not && is_distinct {
5212                self.advance(); // NULLS
5213                self.advance(); // NOT
5214                self.advance(); // DISTINCT
5215                nulls_not_distinct = true;
5216            } else if matches!(n1, Some(Token::Distinct)) {
5217                self.advance(); // NULLS
5218                self.advance(); // DISTINCT
5219            }
5220        }
5221        let columns = self.parse_paren_ident_list("UNIQUE")?;
5222        Ok(crate::ast::TableConstraint::Unique {
5223            name: None,
5224            columns,
5225            nulls_not_distinct,
5226        })
5227    }
5228
5229    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5230    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5231    /// expression.
5232    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5233        self.advance(); // CHECK
5234        if !matches!(self.peek(), Token::LParen) {
5235            return Err(self.err(alloc::format!(
5236                "expected '(' after CHECK, got {:?}",
5237                self.peek()
5238            )));
5239        }
5240        self.advance();
5241        let expr = self.parse_expr(0)?;
5242        if !matches!(self.peek(), Token::RParen) {
5243            return Err(self.err(alloc::format!(
5244                "expected ')' to close CHECK predicate, got {:?}",
5245                self.peek()
5246            )));
5247        }
5248        self.advance();
5249        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5250    }
5251
5252    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5253    fn peek_table_level_check_start(&self) -> bool {
5254        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5255    }
5256
5257    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5258    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5259    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5260    /// own CONSTRAINT prefix).
5261    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5262        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5263            return None;
5264        }
5265        // tokens[pos+1] is the constraint name (any ident-like);
5266        // tokens[pos+2] is the kind keyword.
5267        match self.tokens.get(self.pos + 2) {
5268            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5269                Some(NamedTableConstraintKind::Check)
5270            }
5271            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5272                Some(NamedTableConstraintKind::Unique)
5273            }
5274            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5275                Some(NamedTableConstraintKind::PrimaryKey)
5276            }
5277            _ => None,
5278        }
5279    }
5280
5281    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5282        if !matches!(self.peek(), Token::LParen) {
5283            return Err(self.err(alloc::format!(
5284                "expected '(' after {ctx}, got {:?}",
5285                self.peek()
5286            )));
5287        }
5288        self.advance();
5289        let mut out = Vec::new();
5290        loop {
5291            out.push(self.expect_ident_like()?);
5292            match self.peek() {
5293                Token::Comma => {
5294                    self.advance();
5295                }
5296                Token::RParen => {
5297                    self.advance();
5298                    break;
5299                }
5300                other => {
5301                    return Err(self.err(alloc::format!(
5302                        "expected ',' or ')' in {ctx} list, got {other:?}"
5303                    )));
5304                }
5305            }
5306        }
5307        if out.is_empty() {
5308            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5309        }
5310        Ok(out)
5311    }
5312
5313    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5314    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5315    /// table-level FK; a column def never starts with either keyword
5316    /// (column names are not in this reserved set).
5317    fn peek_constraint_or_fk_start(&self) -> bool {
5318        let is_constraint_kw = matches!(
5319            self.peek(),
5320            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5321        );
5322        let is_foreign_kw = matches!(
5323            self.peek(),
5324            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5325        );
5326        is_constraint_kw || is_foreign_kw
5327    }
5328
5329    /// v7.6.0 — parse a table-level FK clause:
5330    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5331    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5332    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5333        let mut name: Option<String> = None;
5334        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5335            self.advance();
5336            name = Some(self.expect_ident_like()?);
5337        }
5338        // `FOREIGN`
5339        match self.advance() {
5340            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5341            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5342        }
5343        // `KEY`
5344        match self.advance() {
5345            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5346            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5347        }
5348        // `(col, col, ...)`
5349        if !matches!(self.peek(), Token::LParen) {
5350            return Err(self.err(format!(
5351                "expected '(' after FOREIGN KEY, got {:?}",
5352                self.peek()
5353            )));
5354        }
5355        self.advance();
5356        let mut columns = Vec::new();
5357        loop {
5358            columns.push(self.expect_ident_like()?);
5359            match self.peek() {
5360                Token::Comma => {
5361                    self.advance();
5362                }
5363                Token::RParen => {
5364                    self.advance();
5365                    break;
5366                }
5367                other => {
5368                    return Err(self.err(format!(
5369                        "expected ',' or ')' in FK column list, got {other:?}"
5370                    )));
5371                }
5372            }
5373        }
5374        if columns.is_empty() {
5375            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5376        }
5377        let (parent_table, parent_columns, on_delete, on_update) =
5378            self.parse_references_tail(columns.len())?;
5379        Ok(ForeignKeyConstraint {
5380            name,
5381            columns,
5382            parent_table,
5383            parent_columns,
5384            on_delete,
5385            on_update,
5386        })
5387    }
5388
5389    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5390    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5391    /// the local column count, used to default the parent column
5392    /// list when omitted (SQL spec: parent's PK is implied).
5393    fn parse_references_tail(
5394        &mut self,
5395        expected_arity: usize,
5396    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5397        match self.advance() {
5398            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5399            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5400        }
5401        let parent_table = self.expect_ident_like()?;
5402        let mut parent_columns: Vec<String> = Vec::new();
5403        if matches!(self.peek(), Token::LParen) {
5404            self.advance();
5405            loop {
5406                parent_columns.push(self.expect_ident_like()?);
5407                match self.peek() {
5408                    Token::Comma => {
5409                        self.advance();
5410                    }
5411                    Token::RParen => {
5412                        self.advance();
5413                        break;
5414                    }
5415                    other => {
5416                        return Err(self.err(format!(
5417                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5418                        )));
5419                    }
5420                }
5421            }
5422        }
5423        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5424            return Err(self.err(format!(
5425                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5426                expected_arity,
5427                parent_columns.len()
5428            )));
5429        }
5430        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5431        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5432        // <action>` / `ON UPDATE <action>` in either order. PG /
5433        // pg_dump emits the timing clause AFTER the ON clauses
5434        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5435        // but the SQL spec allows either order. We loop over
5436        // every possible trailer and dispatch on the next token,
5437        // stopping when nothing matches. Phase 3.1 changes the
5438        // bare DEFERRABLE form from hard-error to accept-as-
5439        // immediate; SPG is single-writer with no deferred-
5440        // constraint window so the runtime semantics are always
5441        // immediate even when INITIALLY DEFERRED is requested.
5442        let mut on_delete = FkAction::Restrict;
5443        let mut on_update = FkAction::Restrict;
5444        let mut seen_on_delete = false;
5445        let mut seen_on_update = false;
5446        loop {
5447            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5448            let before = self.pos;
5449            self.consume_optional_deferrable_clauses()?;
5450            if self.pos != before {
5451                continue;
5452            }
5453            // ON DELETE / ON UPDATE.
5454            if !matches!(self.peek(), Token::On) {
5455                break;
5456            }
5457            self.advance();
5458            let which = self.advance();
5459            let action = self.parse_fk_action()?;
5460            match which {
5461                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5462                    if seen_on_delete {
5463                        return Err(self.err("ON DELETE specified twice".into()));
5464                    }
5465                    seen_on_delete = true;
5466                    on_delete = action;
5467                }
5468                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5469                    if seen_on_update {
5470                        return Err(self.err("ON UPDATE specified twice".into()));
5471                    }
5472                    seen_on_update = true;
5473                    on_update = action;
5474                }
5475                other => {
5476                    return Err(
5477                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5478                    );
5479                }
5480            }
5481        }
5482        Ok((parent_table, parent_columns, on_delete, on_update))
5483    }
5484
5485    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5486    /// NO ACTION`.
5487    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5488        match self.advance() {
5489            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5490            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5491            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5492                Token::Null => Ok(FkAction::SetNull),
5493                Token::Default => Ok(FkAction::SetDefault),
5494                other => Err(self.err(format!(
5495                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5496                ))),
5497            },
5498            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5499                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5500                other => Err(self.err(format!(
5501                    "expected ACTION after NO in FK action, got {other:?}"
5502                ))),
5503            },
5504            other => Err(self.err(format!(
5505                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5506            ))),
5507        }
5508    }
5509
5510    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5511    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5512    fn consume_if_not_exists(&mut self) -> bool {
5513        // `IF` arrives as a bare Ident (we don't reserve it because it
5514        // also appears mid-expression in PG, though we don't support
5515        // those forms yet).
5516        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5517        if !looks_like_if {
5518            return false;
5519        }
5520        // Peek one ahead before committing: only consume IF when it's
5521        // actually `IF NOT EXISTS`.
5522        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5523            return false;
5524        }
5525        if !matches!(
5526            self.tokens.get(self.pos + 2),
5527            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5528        ) {
5529            return false;
5530        }
5531        self.advance(); // IF
5532        self.advance(); // NOT
5533        self.advance(); // EXISTS
5534        true
5535    }
5536
5537    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5538    /// Consumes IF EXISTS as a pair; returns false otherwise
5539    /// without consuming any tokens.
5540    fn consume_if_exists(&mut self) -> bool {
5541        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5542        if !looks_like_if {
5543            return false;
5544        }
5545        if !matches!(
5546            self.tokens.get(self.pos + 1),
5547            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5548        ) {
5549            return false;
5550        }
5551        self.advance(); // IF
5552        self.advance(); // EXISTS
5553        true
5554    }
5555
5556    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5557    /// qualifiers after an index column ref. ASC / DESC are
5558    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5559    /// We accept and discard them since single-column BTree
5560    /// stores rows in natural key order today.
5561    fn consume_optional_index_column_qualifiers(&mut self) {
5562        loop {
5563            match self.peek() {
5564                Token::Asc | Token::Desc => {
5565                    self.advance();
5566                }
5567                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5568                    let look = self.tokens.get(self.pos + 1);
5569                    if matches!(
5570                        look,
5571                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5572                            || k.eq_ignore_ascii_case("last")
5573                    ) {
5574                        self.advance();
5575                        self.advance();
5576                    } else {
5577                        break;
5578                    }
5579                }
5580                _ => break,
5581            }
5582        }
5583    }
5584
5585    fn parse_create_index_stmt_after_create(
5586        &mut self,
5587        is_unique: bool,
5588    ) -> Result<Statement, ParseError> {
5589        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
5590        debug_assert!(matches!(self.peek(), Token::Index));
5591        self.advance();
5592        let if_not_exists = self.consume_if_not_exists();
5593        let name = self.expect_ident_like()?;
5594        if !matches!(self.peek(), Token::On) {
5595            return Err(self.err(format!(
5596                "expected ON after CREATE INDEX <name>, got {:?}",
5597                self.peek()
5598            )));
5599        }
5600        self.advance();
5601        let table = self.expect_ident_like()?;
5602        // Optional `USING <method>` — only recognised method in v2.0 is
5603        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
5604        // ident `using` (we don't promote it to a reserved keyword
5605        // because it isn't reserved anywhere else in our SQL surface).
5606        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5607            self.advance();
5608            let m = self.expect_ident_like()?;
5609            match m.to_ascii_lowercase().as_str() {
5610                "hnsw" => IndexMethod::Hnsw,
5611                "btree" => IndexMethod::BTree,
5612                "brin" => IndexMethod::Brin,
5613                // v7.12.3 — real GIN inverted index over `tsvector`.
5614                // v7.9.26b's `USING gin` → BTree silent fallback is
5615                // gone; the engine validates that the indexed column
5616                // is `tsvector` at CREATE INDEX time.
5617                "gin" => IndexMethod::Gin,
5618                // v7.9.26b — PG `pg_dump` emits `USING gist` /
5619                // `USING spgist` / `USING hash` for their built-in
5620                // AMs that SPG doesn't have a matching
5621                // implementation for; degrade to BTree on the
5622                // leading column so the schema loads + the index
5623                // catalogue stays consistent. Operator pays the
5624                // planner cost only for the queries that would have
5625                // used the specialised AM.
5626                "gist" | "spgist" | "hash" => IndexMethod::BTree,
5627                // v7.11.3 — pgvector ships both `ivfflat` and
5628                // `hnsw`. Customers shouldn't have to choose
5629                // their on-disk index method based on what SPG
5630                // implements; accept `ivfflat` as a synonym for
5631                // `hnsw` so PG schemas using either method drop
5632                // in. The vector distance op (`<->` / `<#>` /
5633                // `<=>`) at query time still picks the metric.
5634                "ivfflat" => IndexMethod::Hnsw,
5635                other => {
5636                    return Err(self.err(alloc::format!(
5637                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
5638                    )));
5639                }
5640            }
5641        } else {
5642            IndexMethod::BTree
5643        };
5644        if !matches!(self.peek(), Token::LParen) {
5645            return Err(self.err(format!(
5646                "expected '(' before indexed column, got {:?}",
5647                self.peek()
5648            )));
5649        }
5650        self.advance();
5651        // v6.8.2 — accept either a bare column ident (legacy) or
5652        // an expression `fn(col, …)` for expression indexes.
5653        // Distinguish by peeking the token *after* the current
5654        // ident: `ident )` is the legacy column-only path;
5655        // anything else triggers the Pratt expression parser.
5656        // (`advance()` uses `mem::replace` to nil out the current
5657        // slot, so we can't save+rewind cleanly — peek-ahead via
5658        // direct index avoids the mutation.)
5659        let mut opclass: Option<String> = None;
5660        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
5661            // Single column with `)` immediately after — fast path.
5662            // v7.9.29 — also: bare column followed by `,` (the
5663            // multi-column form `(a, b, c)`). Without this branch
5664            // the leading ident gets pulled into `parse_expr`
5665            // which then sets `expression = Some(Column(a))` and
5666            // breaks Display round-trip on the multi-column shape.
5667            Token::Ident(s) | Token::QuotedIdent(s)
5668                if matches!(
5669                    self.tokens.get(self.pos + 1),
5670                    Some(Token::RParen | Token::Comma)
5671                ) =>
5672            {
5673                self.advance();
5674                (s, None)
5675            }
5676            // v7.9.22 — single column followed by a pgvector
5677            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
5678            // v7.15.0 — capture the opclass instead of discarding
5679            // it so the engine can dispatch (e.g. `gin_trgm_ops`
5680            // → real trigram-shingle GIN over a TEXT column).
5681            // Vector/HNSW opclasses still take their distance
5682            // metric from the query operator (`<->` / `<#>` /
5683            // `<=>`), so for those callers the opclass stays
5684            // informational.
5685            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
5686            // opclass: `(embedding public.vector_cosine_ops)`. Strip
5687            // the schema and dispatch on the bare opclass, the same
5688            // treatment table/type names get.
5689            Token::Ident(s) | Token::QuotedIdent(s)
5690                if matches!(
5691                    self.tokens.get(self.pos + 1),
5692                    Some(Token::Ident(_) | Token::QuotedIdent(_))
5693                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
5694                    && matches!(
5695                        self.tokens.get(self.pos + 3),
5696                        Some(Token::Ident(op) | Token::QuotedIdent(op))
5697                            if is_vector_opclass_name(op)
5698                    ) =>
5699            {
5700                self.advance(); // column name
5701                self.advance(); // schema qualifier
5702                self.advance(); // dot
5703                let op_tok = self.advance();
5704                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5705                    opclass = Some(op.to_ascii_lowercase());
5706                }
5707                (s, None)
5708            }
5709            Token::Ident(s) | Token::QuotedIdent(s)
5710                if matches!(
5711                    self.tokens.get(self.pos + 1),
5712                    Some(Token::Ident(op) | Token::QuotedIdent(op))
5713                        if is_vector_opclass_name(op)
5714                ) =>
5715            {
5716                self.advance(); // column name
5717                // Capture the opclass token, lower-cased for
5718                // case-insensitive engine dispatch.
5719                let op_tok = self.advance();
5720                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5721                    opclass = Some(op.to_ascii_lowercase());
5722                }
5723                (s, None)
5724            }
5725            Token::Ident(_) | Token::QuotedIdent(_) => {
5726                let key_expr = self.parse_expr(0)?;
5727                let primary = extract_first_column(&key_expr).ok_or_else(|| {
5728                    self.err("expression index key must reference at least one column".into())
5729                })?;
5730                (primary, Some(key_expr))
5731            }
5732            other => {
5733                return Err(self.err(format!(
5734                    "expected column ident or expression, got {other:?}"
5735                )));
5736            }
5737        };
5738        // v7.9.14 — accept extra comma-separated columns inside
5739        // the index key parens (`CREATE INDEX … (a, b, c)`).
5740        // mailrs F2. Each extra column may carry an optional
5741        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
5742        // — parsed and discarded; SPG doesn't honour direction
5743        // on a BTree index today (column ordering is intrinsic
5744        // to the storage). v7.10 will widen to genuine composite
5745        // index keys.
5746        let mut extra_columns: Vec<String> = Vec::new();
5747        // The leading column may also have ASC/DESC after it.
5748        self.consume_optional_index_column_qualifiers();
5749        while matches!(self.peek(), Token::Comma) {
5750            self.advance();
5751            let extra = self.expect_ident_like()?;
5752            self.consume_optional_index_column_qualifiers();
5753            extra_columns.push(extra);
5754        }
5755        if !matches!(self.peek(), Token::RParen) {
5756            return Err(self.err(format!(
5757                "expected ')' after indexed column / expression, got {:?}",
5758                self.peek()
5759            )));
5760        }
5761        self.advance();
5762        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
5763        // index-only-scan annotation. Bare ident (not a reserved
5764        // keyword) so we test by case-insensitive string match.
5765        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
5766        {
5767            self.advance();
5768            if !matches!(self.peek(), Token::LParen) {
5769                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
5770            }
5771            self.advance();
5772            let mut cols = Vec::new();
5773            loop {
5774                cols.push(self.expect_ident_like()?);
5775                match self.peek() {
5776                    Token::Comma => {
5777                        self.advance();
5778                    }
5779                    Token::RParen => {
5780                        self.advance();
5781                        break;
5782                    }
5783                    other => {
5784                        return Err(self.err(format!(
5785                            "expected ',' or ')' in INCLUDE list, got {other:?}"
5786                        )));
5787                    }
5788                }
5789            }
5790            cols
5791        } else {
5792            Vec::new()
5793        };
5794        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
5795        // storage parameters. pgvector emits `WITH (lists = N)` for
5796        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
5797        // SPG's HNSW picks its own parameters today (tunable via
5798        // env vars), so the WITH clause is informational and dropped.
5799        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
5800            self.advance();
5801            if !matches!(self.peek(), Token::LParen) {
5802                return Err(self.err(format!(
5803                    "expected '(' after WITH in CREATE INDEX, got {:?}",
5804                    self.peek()
5805                )));
5806            }
5807            self.advance();
5808            loop {
5809                if matches!(self.peek(), Token::RParen) {
5810                    self.advance();
5811                    break;
5812                }
5813                // Drain `key = value` or bare `key` tokens.
5814                let _ = self.advance(); // key
5815                if matches!(self.peek(), Token::Eq) {
5816                    self.advance();
5817                    let _ = self.advance(); // value (int / string / ident)
5818                }
5819                match self.peek() {
5820                    Token::Comma => {
5821                        self.advance();
5822                    }
5823                    Token::RParen => {
5824                        self.advance();
5825                        break;
5826                    }
5827                    other => {
5828                        return Err(self.err(format!(
5829                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
5830                        )));
5831                    }
5832                }
5833            }
5834        }
5835        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
5836        let partial_predicate = if matches!(self.peek(), Token::Where) {
5837            self.advance();
5838            Some(self.parse_expr(0)?)
5839        } else {
5840            None
5841        };
5842        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
5843        // sense: uniqueness over an ANN structure has no clean
5844        // semantics. Reject early. (BRIN UNIQUE is similarly
5845        // meaningless — block both.)
5846        if is_unique && !matches!(method, IndexMethod::BTree) {
5847            return Err(self.err(alloc::format!(
5848                "UNIQUE is only supported on BTree indexes, got USING {:?}",
5849                method
5850            )));
5851        }
5852        Ok(Statement::CreateIndex(CreateIndexStatement {
5853            name,
5854            table,
5855            column,
5856            method,
5857            if_not_exists,
5858            included_columns,
5859            partial_predicate,
5860            extra_columns: extra_columns.clone(),
5861            expression,
5862            is_unique,
5863            opclass,
5864        }))
5865    }
5866
5867    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
5868    /// column-level `REFERENCES ...` clause. The trailing FK is
5869    /// normalised into table-level shape (single-element columns +
5870    /// parent_columns) so the engine sees one uniform constraint list.
5871    fn parse_column_def_with_fk(
5872        &mut self,
5873    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
5874        let col = self.parse_column_def()?;
5875        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
5876        let inline_references = matches!(
5877            self.peek(),
5878            Token::Ident(s) if s.eq_ignore_ascii_case("references")
5879        );
5880        if !inline_references {
5881            return Ok((col, None));
5882        }
5883        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
5884        let fk = ForeignKeyConstraint {
5885            name: None,
5886            columns: vec![col.name.clone()],
5887            parent_table,
5888            parent_columns,
5889            on_delete,
5890            on_update,
5891        };
5892        Ok((col, Some(fk)))
5893    }
5894
5895    /// v7.13.0 — parse a column type (consuming the type ident and
5896    /// any trailing parameters / `[]`), without surrounding column
5897    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
5898    /// Returns the resolved `ColumnTypeName` plus implied
5899    /// `(auto_increment, not_null)` flags from PG SERIAL family
5900    /// shorthands — callers that don't expect those (ALTER COLUMN
5901    /// TYPE) can discard them.
5902    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
5903        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
5904        Ok(ty)
5905    }
5906
5907    #[allow(clippy::type_complexity)]
5908    fn parse_type_with_implied_flags(
5909        &mut self,
5910    ) -> Result<
5911        (
5912            ColumnTypeName,
5913            bool,
5914            bool,
5915            Option<String>,
5916            Collation,
5917            bool,
5918            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
5919            // list captured at type-parse time. None for all
5920            // non-ENUM types.
5921            Option<Vec<String>>,
5922            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
5923            // list. Distinct from ENUM (subset semantics).
5924            Option<Vec<String>>,
5925        ),
5926        ParseError,
5927    > {
5928        let mut ty_ident = match self.advance() {
5929            Token::Ident(s) => s,
5930            other => {
5931                return Err(ParseError {
5932                    message: format!("expected column type, got {other:?}"),
5933                    token_pos: self.pos.saturating_sub(1),
5934                });
5935            }
5936        };
5937        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
5938        // pg_dump qualifies extension types (`public.vector(1024)`).
5939        // SPG is single-namespace; drop the schema and resolve the
5940        // bare type — same treatment table names already get.
5941        while matches!(self.peek(), Token::Dot) {
5942            self.advance();
5943            ty_ident = self.expect_ident_like()?;
5944        }
5945        let mut implied_auto_increment = false;
5946        let mut implied_not_null = false;
5947        let mut user_type_ref: Option<String> = None;
5948        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
5949        // value list, captured here and bubbled up through the
5950        // ColumnDef so the engine can attach it to the column
5951        // schema (and validate INSERT cells against it).
5952        let mut inline_enum_variants: Option<Vec<String>> = None;
5953        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
5954        let mut inline_set_variants: Option<Vec<String>> = None;
5955        let mut ty = match ty_ident.as_str() {
5956            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
5957            "smallserial" | "serial2" => {
5958                implied_auto_increment = true;
5959                implied_not_null = true;
5960                ColumnTypeName::SmallInt
5961            }
5962            "serial" | "serial4" => {
5963                implied_auto_increment = true;
5964                implied_not_null = true;
5965                ColumnTypeName::Int
5966            }
5967            "bigserial" | "serial8" => {
5968                implied_auto_increment = true;
5969                implied_not_null = true;
5970                ColumnTypeName::BigInt
5971            }
5972            // MySQL flavours we accept by aliasing to the closest SPG
5973            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
5974            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
5975            // 24-bit) → INT. UNSIGNED modifiers are consumed below
5976            // without semantic effect.
5977            "smallint" => {
5978                // v7.14.0 — MySQL display-width on integers
5979                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
5980                // parenthesised number is purely cosmetic — it
5981                // doesn't change storage. Accept + discard.
5982                self.consume_optional_paren_size();
5983                ColumnTypeName::SmallInt
5984            }
5985            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
5986            // canonical encoding for BOOLEAN. Every MySQL driver
5987            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
5988            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
5989            // 4.3 SPG classified TINYINT(1) as SmallInt, which
5990            // gave the customer i16-shaped values where the app
5991            // expected bool — a Tier-A silent type drift on
5992            // mysqldump restores. Now: `TINYINT(1)` → Bool;
5993            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
5994            // stay SmallInt (the legacy width-agnostic path).
5995            "tinyint" => {
5996                let width = self.peek_optional_paren_size_value();
5997                self.consume_optional_paren_size();
5998                if width == Some(1) {
5999                    ColumnTypeName::Bool
6000                } else {
6001                    ColumnTypeName::SmallInt
6002                }
6003            }
6004            "int" | "integer" | "mediumint" => {
6005                self.consume_optional_paren_size();
6006                ColumnTypeName::Int
6007            }
6008            "bigint" => {
6009                self.consume_optional_paren_size();
6010                ColumnTypeName::BigInt
6011            }
6012            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6013            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6014            // (mailrs round-5 G6). Consume the optional `PRECISION`
6015            // tail when the type keyword was `double` / `DOUBLE`.
6016            "float" | "double" | "real" => {
6017                if ty_ident.eq_ignore_ascii_case("double")
6018                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6019                {
6020                    self.advance();
6021                }
6022                ColumnTypeName::Float
6023            }
6024            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6025            "float4" | "float8" => ColumnTypeName::Float,
6026            "text" => ColumnTypeName::Text,
6027            "bool" | "boolean" => ColumnTypeName::Bool,
6028            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6029            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6030            "vector" => {
6031                let dim = self.parse_paren_size("VECTOR")?;
6032                let encoding = self.parse_optional_vector_encoding()?;
6033                ColumnTypeName::Vector { dim, encoding }
6034            }
6035            "numeric" => {
6036                let (precision, scale) = self.parse_optional_numeric_params()?;
6037                ColumnTypeName::Numeric(precision, scale)
6038            }
6039            "date" => ColumnTypeName::Date,
6040            // MySQL's `DATETIME` is the same domain as standard
6041            // `TIMESTAMP` — accept both spellings.
6042            "timestamp" | "datetime" => {
6043                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6044                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6045                // the full form. SPG canonicalises:
6046                //   - WITH TIME ZONE    → Timestamptz
6047                //   - WITHOUT TIME ZONE → Timestamp
6048                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6049                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6050                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6051                {
6052                    self.advance(); // WITH
6053                    self.advance(); // TIME
6054                    self.advance(); // ZONE
6055                    ColumnTypeName::Timestamptz
6056                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6057                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6058                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6059                {
6060                    self.advance(); // WITHOUT
6061                    self.advance(); // TIME
6062                    self.advance(); // ZONE
6063                    ColumnTypeName::Timestamp
6064                } else {
6065                    // Optional `(precision)` parenthesised modifier
6066                    // (PG fractional seconds precision). SPG stores
6067                    // µs always; accept + discard.
6068                    self.consume_optional_paren_size();
6069                    ColumnTypeName::Timestamp
6070                }
6071            }
6072            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6073            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6074            // only PG-wire OID differs.
6075            "timestamptz" => ColumnTypeName::Timestamptz,
6076            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6077            // validation. We accept the JSONB spelling too because
6078            // most PG clients default to it; SPG doesn't distinguish
6079            // the two (no path-operator perf advantage to model).
6080            "json" => ColumnTypeName::Json,
6081            "jsonb" => ColumnTypeName::Jsonb,
6082            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6083            // surface here. Same storage shape; mapping happens at
6084            // the engine side via the ColumnTypeName → DataType
6085            // resolver. Literal forms are handled at coerce_value
6086            // time so the lexer stays untouched.
6087            "bytea" | "bytes" => ColumnTypeName::Bytes,
6088            // v7.17.0 Phase 7 — PG network address types
6089            // (`inet`, `cidr`, `macaddr`). pg_dump / Django ORM
6090            // emit these for IP-address columns; pre-7 SPG
6091            // errored with "unknown type", breaking schema
6092            // restores. Accept as Text-backed (no new storage
6093            // shape): the customer's data round-trips, and the
6094            // host() / network() helpers added in the same
6095            // phase work textually. Containment operators
6096            // `<<=` / `>>=` are out of v7.17 scope.
6097            "inet" | "cidr" | "macaddr" => ColumnTypeName::Text,
6098            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6099            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6100            // arrives in v7.12.1+; the type itself loads here so
6101            // mailrs's `scripts/init-schema.sql` runs unmodified.
6102            "tsvector" => ColumnTypeName::TsVector,
6103            "tsquery" => ColumnTypeName::TsQuery,
6104            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6105            // surface for Django / Rails / Hibernate's default
6106            // PK pattern.
6107            "uuid" => ColumnTypeName::Uuid,
6108            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6109            // i64 microseconds since 00:00:00. Wire OID 1083.
6110            "time" => ColumnTypeName::Time,
6111            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6112            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6113            "year" => ColumnTypeName::Year,
6114            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6115            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6116            "timetz" => ColumnTypeName::TimeTz,
6117            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6118            // Wire OID 790.
6119            "money" => ColumnTypeName::Money,
6120            // v7.17.0 Phase 3.P0-38 — PG range types.
6121            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6122            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6123            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6124            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6125            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6126            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6127            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6128            "hstore" => ColumnTypeName::Hstore,
6129            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6130            // `ENUM('a','b','c')`. Storage is TEXT; the value
6131            // list lands on `inline_enum_variants` for the
6132            // engine to validate INSERT cells against. Empty
6133            // value list is a parse error (matches MySQL).
6134            "enum" => {
6135                // Expect the opening `(`.
6136                if !matches!(self.peek(), Token::LParen) {
6137                    return Err(self.err(alloc::format!(
6138                        "expected '(' after ENUM, got {:?}",
6139                        self.peek()
6140                    )));
6141                }
6142                self.advance();
6143                let mut variants: Vec<String> = Vec::new();
6144                loop {
6145                    match self.advance() {
6146                        Token::String(s) => variants.push(s),
6147                        other => {
6148                            return Err(self.err(alloc::format!(
6149                                "ENUM(...) expects string literal variants, got {other:?}"
6150                            )));
6151                        }
6152                    }
6153                    match self.peek() {
6154                        Token::Comma => {
6155                            self.advance();
6156                            continue;
6157                        }
6158                        Token::RParen => {
6159                            self.advance();
6160                            break;
6161                        }
6162                        other => {
6163                            return Err(self.err(alloc::format!(
6164                                "expected ',' or ')' in ENUM(...), got {other:?}"
6165                            )));
6166                        }
6167                    }
6168                }
6169                if variants.is_empty() {
6170                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6171                }
6172                inline_enum_variants = Some(variants);
6173                // Storage is plain TEXT; the variant list lives on
6174                // the ColumnSchema side.
6175                ColumnTypeName::Text
6176            }
6177            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6178            // `SET('a','b','c')`. Same parse shape as ENUM;
6179            // semantics differ (subset rather than pick-one).
6180            "set" => {
6181                if !matches!(self.peek(), Token::LParen) {
6182                    return Err(self.err(alloc::format!(
6183                        "expected '(' after SET, got {:?}",
6184                        self.peek()
6185                    )));
6186                }
6187                self.advance();
6188                let mut variants: Vec<String> = Vec::new();
6189                loop {
6190                    match self.advance() {
6191                        Token::String(s) => variants.push(s),
6192                        other => {
6193                            return Err(self.err(alloc::format!(
6194                                "SET(...) expects string literal variants, got {other:?}"
6195                            )));
6196                        }
6197                    }
6198                    match self.peek() {
6199                        Token::Comma => {
6200                            self.advance();
6201                            continue;
6202                        }
6203                        Token::RParen => {
6204                            self.advance();
6205                            break;
6206                        }
6207                        other => {
6208                            return Err(self.err(alloc::format!(
6209                                "expected ',' or ')' in SET(...), got {other:?}"
6210                            )));
6211                        }
6212                    }
6213                }
6214                if variants.is_empty() {
6215                    return Err(self.err("SET(...) must declare at least one variant".into()));
6216                }
6217                inline_set_variants = Some(variants);
6218                ColumnTypeName::Text
6219            }
6220            _other => {
6221                // v7.17.0 Phase 1.4 — unknown ident → defer
6222                // resolution to the engine. Stored as Text in
6223                // ColumnTypeName + the original name carried as
6224                // `user_type_ref` so CREATE TABLE can look up
6225                // user-defined enum / domain types.
6226                user_type_ref = Some(ty_ident.clone());
6227                ColumnTypeName::Text
6228            }
6229        };
6230        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6231        // right after the type keyword. Pre-4.4 SPG consumed +
6232        // discarded the keyword, leaving a customer column
6233        // declared `id INT UNSIGNED NOT NULL` silently accepting
6234        // negative values — a Tier-A correctness drift where
6235        // application invariants (auto-increment-IDs never
6236        // negative) silently broke on cutover. Now: capture as
6237        // a column flag, persist on the schema, enforce at
6238        // INSERT / UPDATE time.
6239        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6240        {
6241            self.advance();
6242            true
6243        } else {
6244            false
6245        };
6246        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6247        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6248        // stores text as UTF-8 always so CHARACTER SET is still a
6249        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6250        // name: it gets classified into a `Collation` variant the
6251        // engine consults at WHERE-eval time. PG `default` /
6252        // `pg_catalog.default` / `C` / `POSIX` collations all
6253        // resolve to `Binary` (the prior behaviour); `_ci` /
6254        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6255        // The schema-qualifier form (`pg_catalog.default`) lexes
6256        // as `Ident '.' Ident` — peek for the `.` and consume both
6257        // halves so it's treated as one collation name. PG's
6258        // `IDENT.IDENT` collation form (which can appear here) is
6259        // resolved by Collation::from_collation_name on the bare
6260        // identifier after the dot.
6261        let mut collation = Collation::Binary;
6262        loop {
6263            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6264                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6265            {
6266                self.advance(); // CHARACTER
6267                self.advance(); // SET
6268                if matches!(
6269                    self.peek(),
6270                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6271                ) {
6272                    self.advance();
6273                }
6274                continue;
6275            }
6276            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6277                self.advance(); // COLLATE
6278                // Accept Ident / QuotedIdent / String AND the
6279                // keyword-tokenised `Default` (PG `pg_catalog.default`
6280                // and bare `DEFAULT` collation names — `default` is a
6281                // reserved word so the lexer hands back Token::Default
6282                // not Token::Ident).
6283                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6284                    match this.peek().clone() {
6285                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6286                            this.advance();
6287                            Some(s)
6288                        }
6289                        Token::Default => {
6290                            this.advance();
6291                            Some(alloc::string::String::from("default"))
6292                        }
6293                        _ => None,
6294                    }
6295                };
6296                let raw = if let Some(head) = read_collation_atom(self) {
6297                    // Schema-qualified PG form: `pg_catalog.default`.
6298                    if matches!(self.peek(), Token::Dot) {
6299                        self.advance();
6300                        let tail = read_collation_atom(self).unwrap_or_default();
6301                        alloc::format!("{head}.{tail}")
6302                    } else {
6303                        head
6304                    }
6305                } else {
6306                    alloc::string::String::new()
6307                };
6308                if !raw.is_empty() {
6309                    let parsed = Collation::from_collation_name(&raw);
6310                    // Last COLLATE clause wins, but `Binary` from a
6311                    // bare keyword like `default` should not
6312                    // silently downgrade a stronger one set earlier
6313                    // on the same column. v7.17 only ships one
6314                    // non-Binary variant so a simple OR is enough.
6315                    if parsed != Collation::Binary {
6316                        collation = parsed;
6317                    }
6318                }
6319                continue;
6320            }
6321            break;
6322        }
6323        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6324        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6325        // so we reject other base types here. mailrs uses TEXT[]
6326        // for labels / addresses / message-on-thread.
6327        if matches!(self.peek(), Token::LBracket) {
6328            self.advance();
6329            if !matches!(self.peek(), Token::RBracket) {
6330                return Err(self.err(alloc::format!(
6331                    "TEXT[] takes no dimension; got {:?}",
6332                    self.peek()
6333                )));
6334            }
6335            self.advance();
6336            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6337            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6338            // still error here.
6339            ty = match ty {
6340                ColumnTypeName::Text => ColumnTypeName::TextArray,
6341                ColumnTypeName::Int => ColumnTypeName::IntArray,
6342                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6343                other => {
6344                    return Err(self.err(alloc::format!(
6345                        "v7.11 supports TEXT[] / INT[] / BIGINT[] only; got {other:?}[]"
6346                    )));
6347                }
6348            };
6349            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6350            // for INT/TEXT/BIGINT. Anything else is an error.
6351            if matches!(self.peek(), Token::LBracket) {
6352                self.advance();
6353                if !matches!(self.peek(), Token::RBracket) {
6354                    return Err(self.err(alloc::format!(
6355                        "TYPE[][] second dimension takes no size; got {:?}",
6356                        self.peek()
6357                    )));
6358                }
6359                self.advance();
6360                ty = match ty {
6361                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6362                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6363                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6364                    other => {
6365                        return Err(self.err(alloc::format!(
6366                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6367                             TEXT[][] only; got {other:?}"
6368                        )));
6369                    }
6370                };
6371            }
6372        }
6373        Ok((
6374            ty,
6375            implied_auto_increment,
6376            implied_not_null,
6377            user_type_ref,
6378            collation,
6379            is_unsigned,
6380            inline_enum_variants,
6381            inline_set_variants,
6382        ))
6383    }
6384
6385    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6386        // v7.20 — PG reserves the table-constraint keywords, so a
6387        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6388        // malformed constraint clause (e.g. `UNIQUE a` missing its
6389        // parens), not a column named "unique". Since v7.17's
6390        // unknown-type leniency (`user_type_ref`) such a clause
6391        // would otherwise parse as a column with a user-defined
6392        // type — silently accepting invalid DDL. Quoted
6393        // identifiers ("unique" / `unique`) remain valid names.
6394        if let Token::Ident(s) = self.peek()
6395            && [
6396                "unique",
6397                "primary",
6398                "foreign",
6399                "constraint",
6400                "check",
6401                "references",
6402                "exclude",
6403            ]
6404            .iter()
6405            .any(|kw| s.eq_ignore_ascii_case(kw))
6406        {
6407            return Err(self.err(alloc::format!(
6408                "unexpected reserved keyword '{s}' at start of column definition \
6409                 (malformed table constraint?)"
6410            )));
6411        }
6412        let name = self.expect_ident_like()?;
6413        let (
6414            ty,
6415            implied_auto_increment,
6416            implied_not_null,
6417            user_type_ref,
6418            collation,
6419            is_unsigned,
6420            inline_enum_variants,
6421            inline_set_variants,
6422        ) = self.parse_type_with_implied_flags()?;
6423        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6424        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6425        // each at most once.
6426        let mut default: Option<Expr> = None;
6427        let mut nullable = !implied_not_null;
6428        let mut nullability_seen = implied_not_null;
6429        let mut auto_increment = implied_auto_increment;
6430        let mut is_primary_key = false;
6431        let mut is_unique = false;
6432        let mut check: Option<Expr> = None;
6433        let mut on_update_runtime: Option<Expr> = None;
6434        loop {
6435            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
6436            // not-null constraints by name and pg_dump emits them
6437            // inline: `id bigint CONSTRAINT contacts_id_not_null1
6438            // NOT NULL`. Accept and discard the name; whatever
6439            // constraint follows is parsed by the arms below.
6440            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
6441                self.advance();
6442                let _name = self.expect_ident_like()?;
6443                continue;
6444            }
6445            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
6446            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
6447            // the modern replacement for SERIAL in hand-written
6448            // schemas). Both flavours map onto the auto-increment
6449            // machinery — SPG's serial semantics ≈ BY DEFAULT;
6450            // ALWAYS's reject-explicit-values nuance is documented
6451            // leniency. Generated EXPRESSION columns
6452            // (`AS (expr) STORED`) are not supported: error loudly
6453            // instead of silently storing NULLs.
6454            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
6455                self.advance();
6456                match self.peek().clone() {
6457                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
6458                        self.advance();
6459                    }
6460                    // `BY` is a reserved keyword token (GROUP BY).
6461                    Token::By => {
6462                        self.advance();
6463                        if !matches!(self.peek(), Token::Default) {
6464                            return Err(self.err(alloc::format!(
6465                                "expected DEFAULT after GENERATED BY, got {:?}",
6466                                self.peek()
6467                            )));
6468                        }
6469                        self.advance();
6470                    }
6471                    other => {
6472                        return Err(self.err(alloc::format!(
6473                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
6474                        )));
6475                    }
6476                }
6477                if !matches!(self.peek(), Token::As) {
6478                    return Err(self.err(alloc::format!(
6479                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
6480                        self.peek()
6481                    )));
6482                }
6483                self.advance();
6484                if matches!(self.peek(), Token::LParen) {
6485                    return Err(self.err(
6486                        "generated expression columns (GENERATED … AS (expr) STORED) \
6487                         are not supported"
6488                            .into(),
6489                    ));
6490                }
6491                self.expect_keyword_ident("identity")?;
6492                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
6493                // consume the balanced parens and discard (SPG's
6494                // auto-increment is max+1-scan based).
6495                if matches!(self.peek(), Token::LParen) {
6496                    let mut depth = 0usize;
6497                    loop {
6498                        match self.advance() {
6499                            Token::LParen => depth += 1,
6500                            Token::RParen => {
6501                                depth -= 1;
6502                                if depth == 0 {
6503                                    break;
6504                                }
6505                            }
6506                            Token::Eof => {
6507                                return Err(self.err(
6508                                    "unterminated sequence-options parens after IDENTITY".into(),
6509                                ));
6510                            }
6511                            _ => {}
6512                        }
6513                    }
6514                }
6515                auto_increment = true;
6516                // PG identity columns are implicitly NOT NULL.
6517                nullable = false;
6518                continue;
6519            }
6520            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
6521            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
6522            // is accepted today. The "ON" token is an Ident
6523            // (not reserved) — peek before consuming.
6524            if matches!(self.peek(), Token::On)
6525                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
6526            {
6527                self.advance(); // ON
6528                self.advance(); // update
6529                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
6530                let next = self.peek().clone();
6531                match next {
6532                    Token::Ident(s) | Token::QuotedIdent(s)
6533                        if s.eq_ignore_ascii_case("current_timestamp") =>
6534                    {
6535                        self.advance();
6536                        // Optional `(N)` precision.
6537                        if matches!(self.peek(), Token::LParen) {
6538                            self.advance();
6539                            if !matches!(self.peek(), Token::Integer(_)) {
6540                                return Err(self.err(alloc::format!(
6541                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
6542                                    self.peek()
6543                                )));
6544                            }
6545                            self.advance();
6546                            if !matches!(self.peek(), Token::RParen) {
6547                                return Err(self.err(alloc::format!(
6548                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
6549                                    self.peek()
6550                                )));
6551                            }
6552                            self.advance();
6553                        }
6554                        on_update_runtime = Some(Expr::FunctionCall {
6555                            name: "now".into(),
6556                            args: Vec::new(),
6557                        });
6558                        continue;
6559                    }
6560                    other => {
6561                        return Err(self.err(alloc::format!(
6562                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
6563                        )));
6564                    }
6565                }
6566            }
6567            if matches!(self.peek(), Token::Default) {
6568                if default.is_some() {
6569                    return Err(self.err("DEFAULT specified twice".into()));
6570                }
6571                self.advance();
6572                default = Some(self.parse_expr(0)?);
6573                continue;
6574            }
6575            if matches!(self.peek(), Token::Not) {
6576                if nullability_seen {
6577                    return Err(self.err("NOT NULL specified twice".into()));
6578                }
6579                self.advance();
6580                if !matches!(self.peek(), Token::Null) {
6581                    return Err(self.err(format!(
6582                        "expected NULL after NOT in column def, got {:?}",
6583                        self.peek()
6584                    )));
6585                }
6586                self.advance();
6587                nullable = false;
6588                nullability_seen = true;
6589                continue;
6590            }
6591            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
6592            // "this column is nullable" marker (the default in
6593            // standard SQL anyway). mysqldump emits it routinely
6594            // (`col TYPE NULL DEFAULT NULL` for nullable
6595            // timestamps etc). Accept + no-op.
6596            if matches!(self.peek(), Token::Null) {
6597                if nullability_seen && !nullable {
6598                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
6599                }
6600                self.advance();
6601                nullable = true;
6602                nullability_seen = true;
6603                continue;
6604            }
6605            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
6606            // arrives as a bare Ident. Match either, case-insensitive.
6607            if let Token::Ident(s) = self.peek()
6608                && (s.eq_ignore_ascii_case("auto_increment")
6609                    || s.eq_ignore_ascii_case("autoincrement"))
6610            {
6611                if auto_increment {
6612                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
6613                }
6614                self.advance();
6615                auto_increment = true;
6616                continue;
6617            }
6618            // v7.9.13 — inline `PRIMARY KEY` column constraint
6619            // (mailrs F1). Implies `NOT NULL`. The engine creates
6620            // a BTree index for the PK column at CREATE TABLE time
6621            // so FK parent-side index lookups resolve.
6622            if let Token::Ident(s) = self.peek()
6623                && s.eq_ignore_ascii_case("primary")
6624            {
6625                if is_primary_key {
6626                    return Err(self.err("PRIMARY KEY specified twice".into()));
6627                }
6628                // Peek-ahead for the required `KEY` token.
6629                let next = self.tokens.get(self.pos + 1);
6630                let next_is_key = matches!(
6631                    next,
6632                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
6633                );
6634                if !next_is_key {
6635                    return Err(self.err(format!(
6636                        "expected KEY after PRIMARY in column def, got {:?}",
6637                        next
6638                    )));
6639                }
6640                self.advance(); // PRIMARY
6641                self.advance(); // KEY
6642                is_primary_key = true;
6643                if nullability_seen && nullable {
6644                    return Err(self.err(
6645                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
6646                    ));
6647                }
6648                nullable = false;
6649                nullability_seen = true;
6650                continue;
6651            }
6652            // v7.13.0 — inline `UNIQUE` column constraint
6653            // (mailrs round-5 G2). Fold into a single-column
6654            // table-level UNIQUE at CREATE TABLE post-process time.
6655            if let Token::Ident(s) = self.peek()
6656                && s.eq_ignore_ascii_case("unique")
6657            {
6658                if is_unique {
6659                    return Err(self.err("UNIQUE specified twice".into()));
6660                }
6661                self.advance();
6662                is_unique = true;
6663                continue;
6664            }
6665            // v7.13.0 — inline `CHECK (<expr>)` column constraint
6666            // (mailrs round-5 G3). PG semantics: column-level
6667            // CHECK is equivalent to a table-level CHECK. Multiple
6668            // inline CHECKs on the same column AND together.
6669            if let Token::Ident(s) = self.peek()
6670                && s.eq_ignore_ascii_case("check")
6671            {
6672                self.advance();
6673                if !matches!(self.peek(), Token::LParen) {
6674                    return Err(self.err(alloc::format!(
6675                        "expected '(' after CHECK in column def, got {:?}",
6676                        self.peek()
6677                    )));
6678                }
6679                self.advance();
6680                let pred = self.parse_expr(0)?;
6681                if !matches!(self.peek(), Token::RParen) {
6682                    return Err(self.err(alloc::format!(
6683                        "expected ')' to close CHECK predicate, got {:?}",
6684                        self.peek()
6685                    )));
6686                }
6687                self.advance();
6688                check = Some(match check.take() {
6689                    Some(prev) => Expr::Binary {
6690                        op: BinOp::And,
6691                        lhs: Box::new(prev),
6692                        rhs: Box::new(pred),
6693                    },
6694                    None => pred,
6695                });
6696                continue;
6697            }
6698            break;
6699        }
6700        Ok(ColumnDef {
6701            name,
6702            ty,
6703            nullable,
6704            default,
6705            auto_increment,
6706            is_primary_key,
6707            is_unique,
6708            check,
6709            user_type_ref,
6710            on_update_runtime,
6711            collation,
6712            is_unsigned,
6713            inline_enum_variants,
6714            inline_set_variants,
6715        })
6716    }
6717
6718    /// `NUMERIC` may appear without parameters, with one (precision
6719    /// only, scale=0), or with both. Returns `(precision, scale)` with
6720    /// 0 = unspecified for the bare form.
6721    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
6722        if !matches!(self.peek(), Token::LParen) {
6723            // Bare `NUMERIC` — PG treats this as "unlimited precision";
6724            // we surface it as precision=0 to mean "unconstrained" so
6725            // the engine doesn't need a separate variant.
6726            return Ok((0, 0));
6727        }
6728        self.advance();
6729        let precision = match self.advance() {
6730            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
6731            other => {
6732                return Err(ParseError {
6733                    message: format!(
6734                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
6735                    ),
6736                    token_pos: self.pos.saturating_sub(1),
6737                });
6738            }
6739        };
6740        let scale = if matches!(self.peek(), Token::Comma) {
6741            self.advance();
6742            match self.advance() {
6743                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
6744                    u8::try_from(n).expect("range-checked")
6745                }
6746                other => {
6747                    return Err(ParseError {
6748                        message: format!(
6749                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
6750                        ),
6751                        token_pos: self.pos.saturating_sub(1),
6752                    });
6753                }
6754            }
6755        } else {
6756            0
6757        };
6758        if !matches!(self.peek(), Token::RParen) {
6759            return Err(self.err(format!(
6760                "expected ')' to close NUMERIC params, got {:?}",
6761                self.peek()
6762            )));
6763        }
6764        self.advance();
6765        Ok((precision, scale))
6766    }
6767
6768    /// Parse `(N)` where `N` is a positive integer literal — used by the
6769    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
6770    /// for the error message.
6771    /// v6.0.1: parse the optional `USING <encoding>` clause that
6772    /// follows `VECTOR(N)` in a column definition. Missing clause
6773    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
6774    /// ident → `ParseError` listing the encodings recognised today.
6775    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
6776        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6777            return Ok(VecEncoding::F32);
6778        }
6779        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
6780        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
6781        // consume the token when the very next token is a known
6782        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
6783        // `USING` for the caller — it's the rewrite-expression form.
6784        let n1 = self.tokens.get(self.pos + 1);
6785        let next_is_encoding = matches!(
6786            n1,
6787            Some(Token::Ident(s))
6788                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
6789        );
6790        if !next_is_encoding {
6791            return Ok(VecEncoding::F32);
6792        }
6793        self.advance();
6794        let enc_ident = match self.advance() {
6795            Token::Ident(s) => s,
6796            other => {
6797                return Err(self.err(format!(
6798                    "expected vector encoding after USING, got {other:?}"
6799                )));
6800            }
6801        };
6802        match enc_ident.to_ascii_lowercase().as_str() {
6803            "sq8" => Ok(VecEncoding::Sq8),
6804            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
6805            // binary16 per-element storage.
6806            "half" => Ok(VecEncoding::F16),
6807            other => Err(self.err(format!(
6808                "unknown vector encoding {other:?}; supported: SQ8, HALF"
6809            ))),
6810        }
6811    }
6812
6813    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
6814    /// without consuming it. Returns `Some(N)` when the next
6815    /// tokens are `( <int> )`; None otherwise. Used by the
6816    /// TINYINT classifier to decide whether to map to Bool or
6817    /// SmallInt.
6818    fn peek_optional_paren_size_value(&self) -> Option<i64> {
6819        if !matches!(self.peek(), Token::LParen) {
6820            return None;
6821        }
6822        let next = self.tokens.get(self.pos + 1)?;
6823        let n = match next {
6824            Token::Integer(n) => *n,
6825            _ => return None,
6826        };
6827        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
6828            return None;
6829        }
6830        Some(n)
6831    }
6832
6833    /// v7.14.0 — consume an optional MySQL display-width
6834    /// parenthesised number after an integer type, returning
6835    /// nothing. `TINYINT(1)` etc.
6836    fn consume_optional_paren_size(&mut self) {
6837        if !matches!(self.peek(), Token::LParen) {
6838            return;
6839        }
6840        self.advance();
6841        // Skip until matching RParen (allow nested or any tokens).
6842        let mut depth = 1usize;
6843        while depth > 0 {
6844            match self.peek() {
6845                Token::LParen => depth += 1,
6846                Token::RParen => depth -= 1,
6847                Token::Eof => return,
6848                _ => {}
6849            }
6850            self.advance();
6851        }
6852    }
6853
6854    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
6855        if !matches!(self.peek(), Token::LParen) {
6856            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
6857        }
6858        self.advance();
6859        let n = match self.advance() {
6860            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
6861                message: format!("{label} size too large: {n}"),
6862                token_pos: self.pos.saturating_sub(1),
6863            })?,
6864            other => {
6865                return Err(ParseError {
6866                    message: format!("expected positive integer {label} size, got {other:?}"),
6867                    token_pos: self.pos.saturating_sub(1),
6868                });
6869            }
6870        };
6871        if !matches!(self.peek(), Token::RParen) {
6872            return Err(self.err(format!(
6873                "expected ')' after {label} size, got {:?}",
6874                self.peek()
6875            )));
6876        }
6877        self.advance();
6878        Ok(n)
6879    }
6880
6881    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
6882        debug_assert!(matches!(self.peek(), Token::Insert));
6883        self.advance();
6884        if !matches!(self.peek(), Token::Into) {
6885            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
6886        }
6887        self.advance();
6888        let table = self.expect_ident_like()?;
6889        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
6890        let columns = if matches!(self.peek(), Token::LParen) {
6891            self.advance();
6892            let mut names = Vec::new();
6893            loop {
6894                names.push(self.expect_ident_like()?);
6895                match self.peek() {
6896                    Token::Comma => {
6897                        self.advance();
6898                    }
6899                    Token::RParen => {
6900                        self.advance();
6901                        break;
6902                    }
6903                    other => {
6904                        return Err(self.err(format!(
6905                            "expected ',' or ')' in INSERT column list, got {other:?}"
6906                        )));
6907                    }
6908                }
6909            }
6910            Some(names)
6911        } else {
6912            None
6913        };
6914        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6915        // round-5 G4). Dispatch on VALUES vs SELECT.
6916        if matches!(self.peek(), Token::Select) {
6917            let select_stmt = match self.parse_select_stmt()? {
6918                Statement::Select(s) => s,
6919                other => {
6920                    return Err(self.err(alloc::format!(
6921                        "expected SELECT after INSERT INTO ... target, got {other:?}"
6922                    )));
6923                }
6924            };
6925            let on_conflict = self.parse_optional_on_conflict()?;
6926            let returning = self.parse_optional_returning()?;
6927            return Ok(Statement::Insert(InsertStatement {
6928                table,
6929                columns,
6930                rows: Vec::new(),
6931                select_source: Some(Box::new(select_stmt)),
6932                on_conflict,
6933                returning,
6934            }));
6935        }
6936        if !matches!(self.peek(), Token::Values) {
6937            return Err(self.err(format!(
6938                "expected VALUES or SELECT after table name, got {:?}",
6939                self.peek()
6940            )));
6941        }
6942        self.advance();
6943        if !matches!(self.peek(), Token::LParen) {
6944            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
6945        }
6946        let mut rows = Vec::new();
6947        loop {
6948            // Each iteration consumes one `(expr, expr, …)` tuple.
6949            if !matches!(self.peek(), Token::LParen) {
6950                return Err(self.err(format!(
6951                    "expected '(' for next VALUES tuple, got {:?}",
6952                    self.peek()
6953                )));
6954            }
6955            self.advance();
6956            let mut tuple = Vec::new();
6957            loop {
6958                tuple.push(self.parse_expr(0)?);
6959                match self.peek() {
6960                    Token::Comma => {
6961                        self.advance();
6962                    }
6963                    Token::RParen => {
6964                        self.advance();
6965                        break;
6966                    }
6967                    other => {
6968                        return Err(self.err(format!(
6969                            "expected ',' or ')' in VALUES tuple, got {other:?}"
6970                        )));
6971                    }
6972                }
6973            }
6974            if tuple.is_empty() {
6975                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
6976            }
6977            rows.push(tuple);
6978            // Continue with comma-separated tuples.
6979            if matches!(self.peek(), Token::Comma) {
6980                self.advance();
6981            } else {
6982                break;
6983            }
6984        }
6985        let on_conflict = self.parse_optional_on_conflict()?;
6986        let returning = self.parse_optional_returning()?;
6987        Ok(Statement::Insert(InsertStatement {
6988            table,
6989            columns,
6990            rows,
6991            select_source: None,
6992            on_conflict,
6993            returning,
6994        }))
6995    }
6996
6997    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
6998    /// clause sitting between the INSERT body and the trailing
6999    /// RETURNING. All keywords come in as bare idents; `ON` is
7000    /// a reserved Token though.
7001    fn parse_optional_on_conflict(
7002        &mut self,
7003    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7004        if !matches!(self.peek(), Token::On) {
7005            return Ok(None);
7006        }
7007        // Peek further: we want exactly "ON CONFLICT ...". If the
7008        // next ident isn't "conflict", let some other parser handle.
7009        let next_is_conflict = matches!(
7010            self.tokens.get(self.pos + 1),
7011            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7012        );
7013        if !next_is_conflict {
7014            return Ok(None);
7015        }
7016        self.advance(); // ON
7017        self.advance(); // CONFLICT
7018        // Optional `(col [, col]*)` target list.
7019        let mut target_columns: Vec<String> = Vec::new();
7020        if matches!(self.peek(), Token::LParen) {
7021            self.advance();
7022            loop {
7023                target_columns.push(self.expect_ident_like()?);
7024                match self.peek() {
7025                    Token::Comma => {
7026                        self.advance();
7027                    }
7028                    Token::RParen => {
7029                        self.advance();
7030                        break;
7031                    }
7032                    other => {
7033                        return Err(self.err(alloc::format!(
7034                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7035                        )));
7036                    }
7037                }
7038            }
7039        }
7040        // Required `DO`.
7041        match self.advance() {
7042            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7043            other => {
7044                return Err(self.err(alloc::format!(
7045                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7046                )));
7047            }
7048        }
7049        // Action: NOTHING | UPDATE SET …
7050        let action = match self.advance() {
7051            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7052                crate::ast::OnConflictAction::Nothing
7053            }
7054            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7055                self.parse_on_conflict_update_action()?
7056            }
7057            other => {
7058                return Err(self.err(alloc::format!(
7059                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7060                )));
7061            }
7062        };
7063        Ok(Some(crate::ast::OnConflictClause {
7064            target_columns,
7065            action,
7066        }))
7067    }
7068
7069    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7070    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7071    /// consumed `UPDATE`.
7072    fn parse_on_conflict_update_action(
7073        &mut self,
7074    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7075        // `SET`
7076        match self.advance() {
7077            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7078            other => {
7079                return Err(self.err(alloc::format!(
7080                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7081                )));
7082            }
7083        }
7084        let mut assignments: Vec<(String, Expr)> = Vec::new();
7085        loop {
7086            let col = self.expect_ident_like()?;
7087            if !matches!(self.peek(), Token::Eq) {
7088                return Err(self.err(alloc::format!(
7089                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7090                    self.peek()
7091                )));
7092            }
7093            self.advance();
7094            let value = self.parse_expr(0)?;
7095            assignments.push((col, value));
7096            if matches!(self.peek(), Token::Comma) {
7097                self.advance();
7098                continue;
7099            }
7100            break;
7101        }
7102        let where_ = if matches!(self.peek(), Token::Where) {
7103            self.advance();
7104            Some(self.parse_expr(0)?)
7105        } else {
7106            None
7107        };
7108        Ok(crate::ast::OnConflictAction::Update {
7109            assignments,
7110            where_,
7111        })
7112    }
7113
7114    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7115        let mut items = Vec::new();
7116        loop {
7117            items.push(self.parse_select_item()?);
7118            if matches!(self.peek(), Token::Comma) {
7119                self.advance();
7120            } else {
7121                break;
7122            }
7123        }
7124        Ok(items)
7125    }
7126
7127    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7128        if matches!(self.peek(), Token::Star) {
7129            self.advance();
7130            return Ok(SelectItem::Wildcard);
7131        }
7132        let expr = self.parse_expr(0)?;
7133        let alias = self.parse_optional_alias();
7134        Ok(SelectItem::Expr { expr, alias })
7135    }
7136
7137    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7138        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7139        // table. Detect at the head so it claims precedence over
7140        // every other table-ref shape (unnest / generate_series /
7141        // bare ident); the lateral subquery itself follows the
7142        // regular SELECT grammar.
7143        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7144            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7145        {
7146            self.advance(); // LATERAL
7147            self.advance(); // (
7148            // Parse the inner SELECT.
7149            let inner = match self.parse_one_statement()? {
7150                Statement::Select(s) => s,
7151                other => {
7152                    return Err(self.err(alloc::format!(
7153                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7154                    )));
7155                }
7156            };
7157            if !matches!(self.peek(), Token::RParen) {
7158                return Err(self.err(alloc::format!(
7159                    "expected ')' after LATERAL subquery, got {:?}",
7160                    self.peek()
7161                )));
7162            }
7163            self.advance();
7164            let alias_ident = self.parse_optional_alias();
7165            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7166            return Ok(TableRef {
7167                name,
7168                alias: alias_ident,
7169                as_of_segment: None,
7170                unnest_expr: None,
7171                unnest_column_aliases: Vec::new(),
7172                generate_series_args: None,
7173                lateral_subquery: Some(Box::new(inner)),
7174            });
7175        }
7176        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
7177        // source. Detect at the head before the bare-ident fallback;
7178        // unnest is not a reserved token.
7179        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
7180            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7181        {
7182            self.advance(); // unnest
7183            self.advance(); // (
7184            let expr = self.parse_expr(0)?;
7185            if !matches!(self.peek(), Token::RParen) {
7186                return Err(self.err(alloc::format!(
7187                    "expected ')' after unnest() argument, got {:?}",
7188                    self.peek()
7189                )));
7190            }
7191            self.advance();
7192            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
7193            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
7194            return Ok(TableRef {
7195                name,
7196                alias: alias_ident,
7197                as_of_segment: None,
7198                unnest_expr: Some(Box::new(expr)),
7199                unnest_column_aliases,
7200                generate_series_args: None,
7201                lateral_subquery: None,
7202            });
7203        }
7204        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7205        // [, step])` set-returning source. Same shape as unnest:
7206        // detect at the head, parse the comma-separated arg list,
7207        // dispatch downstream through the engine's set-returning
7208        // path. Supports integer triplets (mailrs's `WITH row_no AS
7209        // (SELECT * FROM generate_series(1, N))` pattern) and
7210        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
7211        // date-range iteration pattern, which pre-3.10 had no
7212        // direct equivalent in SPG).
7213        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
7214            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7215        {
7216            self.advance(); // generate_series
7217            self.advance(); // (
7218            let mut args: Vec<Expr> = Vec::new();
7219            loop {
7220                args.push(self.parse_expr(0)?);
7221                if matches!(self.peek(), Token::Comma) {
7222                    self.advance();
7223                    continue;
7224                }
7225                break;
7226            }
7227            if !matches!(self.peek(), Token::RParen) {
7228                return Err(self.err(alloc::format!(
7229                    "expected ')' after generate_series() arguments, got {:?}",
7230                    self.peek()
7231                )));
7232            }
7233            self.advance();
7234            if args.len() < 2 || args.len() > 3 {
7235                return Err(self.err(alloc::format!(
7236                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
7237                    args.len()
7238                )));
7239            }
7240            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7241            let name = alias_ident
7242                .clone()
7243                .unwrap_or_else(|| "generate_series".to_string());
7244            return Ok(TableRef {
7245                name,
7246                alias: alias_ident,
7247                as_of_segment: None,
7248                unnest_expr: None,
7249                unnest_column_aliases: Vec::new(),
7250                generate_series_args: Some(args),
7251                lateral_subquery: None,
7252            });
7253        }
7254        // v7.16.2 — preserve information_schema / pg_catalog
7255        // qualifiers (mailrs round-10 A.3). The generic
7256        // `expect_ident_like` strip silently drops the schema;
7257        // we want the engine to recognise these PG meta tables
7258        // and synthesise rows from the live catalog. Produce a
7259        // synthetic name (`__spg_info_columns` etc.) so the
7260        // engine's SELECT-side router can dispatch without
7261        // clashing with any user-defined `columns` table.
7262        let name = if let Some(synth) = self.try_peek_meta_qualified() {
7263            synth
7264        } else if let Some(synth) = self.try_peek_meta_bare() {
7265            synth
7266        } else {
7267            self.expect_ident_like()?
7268        };
7269        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
7270        // time-travel clause. Parse BEFORE the alias so the
7271        // alias can still ride at the tail (`tbl AS OF SEGMENT
7272        // '5' alias`). `AS` is a reserved keyword token, while
7273        // `OF` and `SEGMENT` are bare idents.
7274        let as_of_segment = if matches!(self.peek(), Token::As)
7275            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
7276        {
7277            self.advance(); // AS
7278            self.advance(); // OF
7279            let kw = match self.peek().clone() {
7280                Token::Ident(s) | Token::QuotedIdent(s) => s,
7281                other => {
7282                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
7283                }
7284            };
7285            if !kw.eq_ignore_ascii_case("segment") {
7286                return Err(self.err(format!(
7287                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
7288                )));
7289            }
7290            self.advance();
7291            // Segment id literal — accept either a string or
7292            // integer for operator ergonomics.
7293            let id = match self.advance() {
7294                Token::String(s) => s
7295                    .parse::<u32>()
7296                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7297                Token::Integer(n) => u32::try_from(n)
7298                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7299                other => {
7300                    return Err(self.err(format!(
7301                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
7302                    )));
7303                }
7304            };
7305            Some(id)
7306        } else {
7307            None
7308        };
7309        let alias = self.parse_optional_alias();
7310        Ok(TableRef {
7311            name,
7312            alias,
7313            as_of_segment,
7314            unnest_expr: None,
7315            unnest_column_aliases: Vec::new(),
7316            generate_series_args: None,
7317            lateral_subquery: None,
7318        })
7319    }
7320
7321    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
7322    /// but also accepts `AS alias(col [, col, …])` — the
7323    /// PG-standard table-function column-list form. The column
7324    /// list is only honoured when paired with `UNNEST(...)` in
7325    /// the parent; other call sites currently discard it.
7326    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
7327        let alias = self.parse_optional_alias();
7328        if alias.is_none() {
7329            return (None, Vec::new());
7330        }
7331        let mut cols: Vec<String> = Vec::new();
7332        if matches!(self.peek(), Token::LParen) {
7333            self.advance();
7334            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
7335                self.advance();
7336                cols.push(s);
7337                if matches!(self.peek(), Token::Comma) {
7338                    self.advance();
7339                    continue;
7340                }
7341                break;
7342            }
7343            if matches!(self.peek(), Token::RParen) {
7344                self.advance();
7345            }
7346        }
7347        (alias, cols)
7348    }
7349
7350    /// FROM-clause: a primary table reference plus zero-or-more joined
7351    /// peers expressed via either `, <table>` (cross-product, no ON) or
7352    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
7353    /// the join list flat (left-associative nested-loop semantics).
7354    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
7355        let primary = self.parse_table_ref()?;
7356        let mut joins = Vec::new();
7357        loop {
7358            // `, <table>` — cross-product with no ON.
7359            if matches!(self.peek(), Token::Comma) {
7360                self.advance();
7361                let table = self.parse_table_ref()?;
7362                joins.push(FromJoin {
7363                    kind: JoinKind::Cross,
7364                    table,
7365                    on: None,
7366                });
7367                continue;
7368            }
7369            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
7370            // CROSS JOIN, and bare JOIN (defaults to INNER).
7371            let kind =
7372                match self.peek() {
7373                    Token::Inner => {
7374                        self.advance();
7375                        if !matches!(self.peek(), Token::Join) {
7376                            return Err(self
7377                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
7378                        }
7379                        self.advance();
7380                        JoinKind::Inner
7381                    }
7382                    Token::Left => {
7383                        self.advance();
7384                        if matches!(self.peek(), Token::Outer) {
7385                            self.advance();
7386                        }
7387                        if !matches!(self.peek(), Token::Join) {
7388                            return Err(self.err(format!(
7389                                "expected JOIN after LEFT [OUTER], got {:?}",
7390                                self.peek()
7391                            )));
7392                        }
7393                        self.advance();
7394                        JoinKind::Left
7395                    }
7396                    Token::Cross => {
7397                        self.advance();
7398                        if !matches!(self.peek(), Token::Join) {
7399                            return Err(self
7400                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
7401                        }
7402                        self.advance();
7403                        JoinKind::Cross
7404                    }
7405                    Token::Join => {
7406                        self.advance();
7407                        JoinKind::Inner
7408                    }
7409                    _ => break,
7410                };
7411            let table = self.parse_table_ref()?;
7412            let on = if matches!(self.peek(), Token::On) {
7413                self.advance();
7414                Some(self.parse_expr(0)?)
7415            } else if kind == JoinKind::Cross {
7416                None
7417            } else {
7418                return Err(self.err(format!(
7419                    "expected ON after {:?} JOIN, got {:?}",
7420                    kind,
7421                    self.peek()
7422                )));
7423            };
7424            joins.push(FromJoin { kind, table, on });
7425        }
7426        Ok(FromClause { primary, joins })
7427    }
7428
7429    /// Optional alias after an expression or table:
7430    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
7431    /// accepted (PG-style implicit alias). Returns `None` if the next token
7432    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
7433    fn parse_optional_alias(&mut self) -> Option<String> {
7434        if matches!(self.peek(), Token::As) {
7435            self.advance();
7436            // After AS, the next token MUST be an identifier-like — if not,
7437            // we still return None and let the caller surface the error on the
7438            // next expectation. v0.2 keeps the alias path forgiving; the
7439            // corpus tests don't exercise the malformed case.
7440            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
7441                return self.expect_ident_like().ok();
7442            }
7443            return None;
7444        }
7445        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
7446        // grammar reserves a long list of follow-keywords from the
7447        // alias slot. SPG's bareword approximation: skip a small
7448        // set of idents that would otherwise be swallowed as the
7449        // table alias and break trailing clauses like CREATE
7450        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
7451        // CONFLICT WHERE shapes.
7452        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
7453            if is_alias_stopword(s) {
7454                return None;
7455            }
7456            return self.expect_ident_like().ok();
7457        }
7458        None
7459    }
7460
7461    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
7462    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7463        let mut lhs = self.parse_unary()?;
7464        while let Some((op, prec)) = binop_from(self.peek()) {
7465            if prec < min_prec {
7466                break;
7467            }
7468            self.advance();
7469            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
7470            // ANY is a bare ident; ALL is a reserved Token. Both
7471            // require an immediate `(` to disambiguate from
7472            // identifier columns named `any` / `all`.
7473            let any_kind = match self.peek() {
7474                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
7475                    Some(false)
7476                }
7477                Token::Ident(s) | Token::QuotedIdent(s)
7478                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
7479                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
7480                {
7481                    Some(s.eq_ignore_ascii_case("any"))
7482                }
7483                _ => None,
7484            };
7485            if let Some(is_any) = any_kind {
7486                self.advance(); // ident
7487                self.advance(); // (
7488                let arr = self.parse_expr(0)?;
7489                if !matches!(self.peek(), Token::RParen) {
7490                    return Err(self.err(alloc::format!(
7491                        "expected ')' after ANY/ALL argument, got {:?}",
7492                        self.peek()
7493                    )));
7494                }
7495                self.advance();
7496                lhs = Expr::AnyAll {
7497                    expr: Box::new(lhs),
7498                    op,
7499                    array: Box::new(arr),
7500                    is_any,
7501                };
7502                continue;
7503            }
7504            let rhs = self.parse_expr(prec + 1)?;
7505            lhs = Expr::Binary {
7506                lhs: Box::new(lhs),
7507                op,
7508                rhs: Box::new(rhs),
7509            };
7510        }
7511        Ok(lhs)
7512    }
7513
7514    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
7515        match self.peek() {
7516            Token::Not => {
7517                self.advance();
7518                // NOT sits between AND (2) and comparisons (4) — bind everything
7519                // ≥3, which leaves AND/OR outside.
7520                let e = self.parse_expr(3)?;
7521                Ok(Expr::Unary {
7522                    op: UnOp::Not,
7523                    expr: Box::new(e),
7524                })
7525            }
7526            Token::Minus => {
7527                self.advance();
7528                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
7529                // `<->` slotted into 5 and arithmetic shifted up).
7530                let e = self.parse_expr(8)?;
7531                Ok(Expr::Unary {
7532                    op: UnOp::Neg,
7533                    expr: Box::new(e),
7534                })
7535            }
7536            Token::Tilde => {
7537                self.advance();
7538                // Bitwise NOT binds like unary minus.
7539                let e = self.parse_expr(8)?;
7540                Ok(Expr::Unary {
7541                    op: UnOp::BitNot,
7542                    expr: Box::new(e),
7543                })
7544            }
7545            _ => self.parse_atom(),
7546        }
7547    }
7548
7549    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
7550        let tok_pos = self.pos;
7551        match self.advance() {
7552            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
7553            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
7554            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
7555            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
7556            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
7557            Token::Null => Ok(Expr::Literal(Literal::Null)),
7558            // v6.1.1 — `$N` placeholder. The actual Value lookup
7559            // happens in the engine eval path against the prepared-
7560            // statement bind buffer.
7561            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
7562            Token::LParen => {
7563                // v4.10: `(SELECT ...)` in expression position is a
7564                // scalar subquery; otherwise it's a parenthesised
7565                // expression. Peek for SELECT keyword to dispatch.
7566                if matches!(self.peek(), Token::Select) {
7567                    let inner = self.parse_select_stmt()?;
7568                    match self.advance() {
7569                        Token::RParen => {
7570                            let Statement::Select(s) = inner else {
7571                                unreachable!("parse_select_stmt returns Select")
7572                            };
7573                            Ok(Expr::ScalarSubquery(Box::new(s)))
7574                        }
7575                        other => Err(ParseError {
7576                            message: format!("expected ')' after scalar subquery, got {other:?}"),
7577                            token_pos: self.pos.saturating_sub(1),
7578                        }),
7579                    }
7580                } else {
7581                    let e = self.parse_expr(0)?;
7582                    match self.advance() {
7583                        Token::RParen => Ok(e),
7584                        other => Err(ParseError {
7585                            message: format!("expected ')', got {other:?}"),
7586                            token_pos: self.pos.saturating_sub(1),
7587                        }),
7588                    }
7589                }
7590            }
7591            Token::LBracket => self.parse_vector_literal_body(),
7592            Token::Extract => self.parse_extract_atom(),
7593            Token::Interval => self.parse_interval_atom(),
7594            // `LEFT` is a reserved-keyword token because the
7595            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
7596            // When `left` is followed by `(` we're in expression
7597            // position calling the PG `left(string, n)` function;
7598            // rebuild the AST as a regular function call so the
7599            // engine's apply_function dispatch picks it up.
7600            Token::Left if matches!(self.peek(), Token::LParen) => {
7601                self.advance(); // (
7602                let mut args = Vec::new();
7603                if !matches!(self.peek(), Token::RParen) {
7604                    loop {
7605                        args.push(self.parse_expr(0)?);
7606                        match self.peek() {
7607                            Token::Comma => {
7608                                self.advance();
7609                            }
7610                            Token::RParen => break,
7611                            other => {
7612                                return Err(self.err(alloc::format!(
7613                                    "expected ',' or ')' in left() args, got {other:?}"
7614                                )));
7615                            }
7616                        }
7617                    }
7618                }
7619                self.advance(); // )
7620                Ok(Expr::FunctionCall {
7621                    name: "left".into(),
7622                    args,
7623                })
7624            }
7625            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
7626            // token; we match on the bare ident. NOT is a token
7627            // (consumed in the comparison rung), but `EXISTS (...)`
7628            // at the top of an expression starts here.
7629            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
7630                self.parse_exists_atom(false)
7631            }
7632            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
7633            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
7634            // CASE is a bare ident; we dispatch on lowercase match.
7635            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
7636                self.parse_case_atom()
7637            }
7638            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
7639            // is not a reserved token; we match by case-insensitive
7640            // ident. The opening `[` must follow immediately.
7641            Token::Ident(s) | Token::QuotedIdent(s)
7642                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
7643            {
7644                self.advance(); // consume `[`
7645                let mut items: Vec<Expr> = Vec::new();
7646                if !matches!(self.peek(), Token::RBracket) {
7647                    loop {
7648                        items.push(self.parse_expr(0)?);
7649                        match self.peek() {
7650                            Token::Comma => {
7651                                self.advance();
7652                            }
7653                            Token::RBracket => break,
7654                            other => {
7655                                return Err(self.err(alloc::format!(
7656                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
7657                                )));
7658                            }
7659                        }
7660                    }
7661                }
7662                self.advance(); // consume `]`
7663                Ok(Expr::Array(items))
7664            }
7665            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
7666            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
7667            // We special-case before the generic ident dispatch so
7668            // the AGAINST clause never reaches the function-call
7669            // loop (which would mis-read `(cols) AGAINST` as a
7670            // call with no trailing modifier). The shape is
7671            // rewritten to a Boolean OR over per-column
7672            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7673            // term)` so the existing FTS evaluator handles
7674            // semantics — the fulltext-GIN built at CREATE TABLE
7675            // time is currently a "real index that survives dump
7676            // round-trip"; the planner hook that actually uses
7677            // it for posting-list intersection lands in a later
7678            // sub-phase (Phase 2.2b) without touching this surface.
7679            Token::Ident(s) | Token::QuotedIdent(s)
7680                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
7681            {
7682                self.parse_match_against_atom()
7683            }
7684            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
7685            other => Err(ParseError {
7686                message: format!("unexpected token {other:?} in expression"),
7687                token_pos: tok_pos,
7688            }),
7689        }
7690        // After parsing the atom, fold any postfix `::vector` casts.
7691        .and_then(|atom| self.finish_postfix_casts(atom))
7692    }
7693
7694    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
7695    /// Both bind tighter than any binary op.
7696    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
7697        loop {
7698            if matches!(self.peek(), Token::DoubleColon) {
7699                self.advance();
7700                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
7701                // target set to include INTERVAL (reserved Token),
7702                // TIMESTAMPTZ, and PG catalog regtype / regclass.
7703                // mailrs follow-up H3a + H3b.
7704                let target = match self.advance() {
7705                    Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
7706                        "int" | "integer" | "int4" => {
7707                            if matches!(self.peek(), Token::LBracket)
7708                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7709                            {
7710                                self.advance();
7711                                self.advance();
7712                                CastTarget::IntArray
7713                            } else {
7714                                CastTarget::Int
7715                            }
7716                        }
7717                        "bigint" | "int8" => {
7718                            if matches!(self.peek(), Token::LBracket)
7719                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7720                            {
7721                                self.advance();
7722                                self.advance();
7723                                CastTarget::BigIntArray
7724                            } else {
7725                                CastTarget::BigInt
7726                            }
7727                        }
7728                        "float" | "double" | "real" => CastTarget::Float,
7729                        "text" => {
7730                            // v7.10.11 — `::TEXT[]` widens to TextArray.
7731                            if matches!(self.peek(), Token::LBracket)
7732                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7733                            {
7734                                self.advance();
7735                                self.advance();
7736                                CastTarget::TextArray
7737                            } else {
7738                                CastTarget::Text
7739                            }
7740                        }
7741                        "bool" | "boolean" => CastTarget::Bool,
7742                        "vector" => CastTarget::Vector,
7743                        "date" => CastTarget::Date,
7744                        "timestamp" | "datetime" => CastTarget::Timestamp,
7745                        "timestamptz" => CastTarget::Timestamptz,
7746                        "interval" => CastTarget::Interval,
7747                        "json" => CastTarget::Json,
7748                        "jsonb" => CastTarget::Jsonb,
7749                        "regtype" => CastTarget::RegType,
7750                        "regclass" => CastTarget::RegClass,
7751                        // v7.12.0 — `::tsvector` / `::tsquery`.
7752                        // Engine decodes the LHS text via the PG
7753                        // external form parser.
7754                        "tsvector" => CastTarget::TsVector,
7755                        "tsquery" => CastTarget::TsQuery,
7756                        // v7.17.0 — `::uuid`. Engine decodes the LHS
7757                        // text via `spg_storage::parse_uuid_str`.
7758                        "uuid" => CastTarget::Uuid,
7759                        // v7.18 — `::bytea`. Engine decodes the LHS
7760                        // text via the PG hex form (`'\xdeadbeef'`)
7761                        // or escape form (`'\\x05\\x00'`). Closes
7762                        // mailrs D-pre #3 reverse-acceptance gap.
7763                        "bytea" => CastTarget::Bytea,
7764                        // v7.17.0 Phase 3.P0-47 — `::inet` / `::cidr` /
7765                        // `::macaddr`. SPG stores these as Text (Phase 7);
7766                        // the cast is a no-op passthrough so containment
7767                        // and overlap operators can read the textual form.
7768                        "inet" | "cidr" | "macaddr" => CastTarget::Text,
7769                        other => {
7770                            return Err(ParseError {
7771                                message: format!("unsupported cast target `::{other}`"),
7772                                token_pos: self.pos.saturating_sub(1),
7773                            });
7774                        }
7775                    },
7776                    Token::Interval => CastTarget::Interval,
7777                    other => {
7778                        return Err(ParseError {
7779                            message: format!("expected type ident after `::`, got {other:?}"),
7780                            token_pos: self.pos.saturating_sub(1),
7781                        });
7782                    }
7783                };
7784                expr = Expr::Cast {
7785                    expr: Box::new(expr),
7786                    target,
7787                };
7788                continue;
7789            }
7790            if matches!(self.peek(), Token::Is) {
7791                self.advance();
7792                let negated = if matches!(self.peek(), Token::Not) {
7793                    self.advance();
7794                    true
7795                } else {
7796                    false
7797                };
7798                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
7799                // mailrs pg_dump.
7800                if matches!(self.peek(), Token::Distinct) {
7801                    self.advance();
7802                    if !matches!(self.peek(), Token::From) {
7803                        return Err(self.err(format!(
7804                            "expected FROM after IS{} DISTINCT, got {:?}",
7805                            if negated { " NOT" } else { "" },
7806                            self.peek()
7807                        )));
7808                    }
7809                    self.advance();
7810                    // Right-hand side: parse at the same precedence
7811                    // tier as comparison so `x IS DISTINCT FROM a + b`
7812                    // groups as `x IS DISTINCT FROM (a + b)`.
7813                    let rhs = self.parse_expr(20)?;
7814                    let op = if negated {
7815                        BinOp::IsNotDistinctFrom
7816                    } else {
7817                        BinOp::IsDistinctFrom
7818                    };
7819                    expr = Expr::Binary {
7820                        op,
7821                        lhs: Box::new(expr),
7822                        rhs: Box::new(rhs),
7823                    };
7824                    continue;
7825                }
7826                if !matches!(self.peek(), Token::Null) {
7827                    return Err(self.err(format!(
7828                        "expected NULL or DISTINCT after IS{}, got {:?}",
7829                        if negated { " NOT" } else { "" },
7830                        self.peek()
7831                    )));
7832                }
7833                self.advance();
7834                expr = Expr::IsNull {
7835                    expr: Box::new(expr),
7836                    negated,
7837                };
7838                continue;
7839            }
7840            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
7841            // Look one token ahead so a stray `NOT` not followed by any of
7842            // these flows through to the early return below untouched.
7843            let negated = if matches!(self.peek(), Token::Not) {
7844                let next = self.tokens.get(self.pos + 1);
7845                matches!(next, Some(Token::Between | Token::In | Token::Like))
7846            } else {
7847                false
7848            };
7849            if negated {
7850                self.advance();
7851            }
7852            if matches!(self.peek(), Token::Between) {
7853                expr = self.parse_between_tail(expr, negated)?;
7854                continue;
7855            }
7856            if matches!(self.peek(), Token::In) {
7857                expr = self.parse_in_tail(expr, negated)?;
7858                continue;
7859            }
7860            if matches!(self.peek(), Token::Like) {
7861                self.advance();
7862                // Pattern at the same precedence as other comparison RHSes —
7863                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
7864                let pattern = self.parse_expr(5)?;
7865                expr = Expr::Like {
7866                    expr: Box::new(expr),
7867                    pattern: Box::new(pattern),
7868                    negated,
7869                };
7870                continue;
7871            }
7872            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
7873            // returns NULL for out-of-range. Multiple subscripts
7874            // chain: `a[i][j]` parses left-to-right.
7875            if matches!(self.peek(), Token::LBracket) {
7876                self.advance();
7877                let index = self.parse_expr(0)?;
7878                if !matches!(self.peek(), Token::RBracket) {
7879                    return Err(self.err(alloc::format!(
7880                        "expected ']' after array index, got {:?}",
7881                        self.peek()
7882                    )));
7883                }
7884                self.advance();
7885                expr = Expr::ArraySubscript {
7886                    target: Box::new(expr),
7887                    index: Box::new(index),
7888                };
7889                continue;
7890            }
7891            return Ok(expr);
7892        }
7893    }
7894
7895    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
7896    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
7897    /// `AND` is not swallowed.
7898    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7899        self.advance(); // BETWEEN
7900        let low = self.parse_expr(5)?;
7901        if !matches!(self.peek(), Token::And) {
7902            return Err(self.err(format!(
7903                "expected AND after BETWEEN low bound, got {:?}",
7904                self.peek()
7905            )));
7906        }
7907        self.advance();
7908        let high = self.parse_expr(5)?;
7909        let target = Box::new(expr);
7910        let combined = Expr::Binary {
7911            lhs: Box::new(Expr::Binary {
7912                lhs: target.clone(),
7913                op: BinOp::GtEq,
7914                rhs: Box::new(low),
7915            }),
7916            op: BinOp::And,
7917            rhs: Box::new(Expr::Binary {
7918                lhs: target,
7919                op: BinOp::LtEq,
7920                rhs: Box::new(high),
7921            }),
7922        };
7923        Ok(maybe_not(combined, negated))
7924    }
7925
7926    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
7927    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
7928    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
7929    /// Caller already consumed the leading `WITH` ident.
7930    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
7931        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
7932        // Comes through as an identifier; consume it if present and
7933        // mark every CTE in the clause as recursive (PG semantics —
7934        // the flag is per-WITH, not per-CTE).
7935        let mut recursive = false;
7936        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
7937            && s.eq_ignore_ascii_case("recursive")
7938        {
7939            self.advance();
7940            recursive = true;
7941        }
7942        let mut ctes = Vec::new();
7943        loop {
7944            let name = self.expect_ident_like()?;
7945            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
7946            // PG uses these to rename the body's output columns; we
7947            // do the same below by overriding `columns[i].name`.
7948            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
7949                self.advance();
7950                let mut names = Vec::new();
7951                loop {
7952                    names.push(self.expect_ident_like()?);
7953                    if matches!(self.peek(), Token::Comma) {
7954                        self.advance();
7955                        continue;
7956                    }
7957                    break;
7958                }
7959                if !matches!(self.peek(), Token::RParen) {
7960                    return Err(self.err(format!(
7961                        "expected ')' to close CTE column list, got {:?}",
7962                        self.peek()
7963                    )));
7964                }
7965                self.advance();
7966                names
7967            } else {
7968                Vec::new()
7969            };
7970            // AS is a reserved Token::As (used by SELECT-item / FROM
7971            // aliasing) — handle it specially rather than as a bare
7972            // ident.
7973            if !matches!(self.peek(), Token::As) {
7974                return Err(self.err(format!(
7975                    "expected AS after CTE name {name:?}, got {:?}",
7976                    self.peek()
7977                )));
7978            }
7979            self.advance();
7980            if !matches!(self.peek(), Token::LParen) {
7981                return Err(self.err(format!(
7982                    "expected '(' after AS in WITH clause, got {:?}",
7983                    self.peek()
7984                )));
7985            }
7986            self.advance();
7987            if !matches!(self.peek(), Token::Select) {
7988                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
7989            }
7990            let inner = self.parse_select_stmt()?;
7991            if !matches!(self.peek(), Token::RParen) {
7992                return Err(self.err(format!(
7993                    "expected ')' after CTE body, got {:?}",
7994                    self.peek()
7995                )));
7996            }
7997            self.advance();
7998            let Statement::Select(body) = inner else {
7999                unreachable!("parse_select_stmt returns Select")
8000            };
8001            ctes.push(crate::ast::Cte {
8002                name,
8003                body,
8004                recursive,
8005                column_overrides,
8006            });
8007            if matches!(self.peek(), Token::Comma) {
8008                self.advance();
8009                continue;
8010            }
8011            break;
8012        }
8013        // The body SELECT follows. Must start with SELECT.
8014        if !matches!(self.peek(), Token::Select) {
8015            return Err(self.err(format!(
8016                "expected SELECT after WITH clause, got {:?}",
8017                self.peek()
8018            )));
8019        }
8020        let body_stmt = self.parse_select_stmt()?;
8021        let Statement::Select(mut body) = body_stmt else {
8022            unreachable!()
8023        };
8024        body.ctes = ctes;
8025        Ok(Statement::Select(body))
8026    }
8027
8028    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
8029    /// already consumed the leading `EXISTS` ident via
8030    /// `self.advance()`.
8031    /// v7.13.0 — parse the rest of a `CASE … END` expression after
8032    /// the leading `CASE` ident has been consumed (mailrs round-5
8033    /// G9). Supports both the searched form
8034    /// (`CASE WHEN cond THEN val …`) and the simple form
8035    /// (`CASE operand WHEN val THEN val …`).
8036    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
8037        // Disambiguate searched vs simple form: if the next token
8038        // is `WHEN`, we're in the searched form. Otherwise the
8039        // intervening expression is the operand.
8040        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
8041            None
8042        } else {
8043            Some(Box::new(self.parse_expr(0)?))
8044        };
8045        let mut branches: Vec<(Expr, Expr)> = Vec::new();
8046        loop {
8047            match self.peek() {
8048                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
8049                    self.advance();
8050                    let cond = self.parse_expr(0)?;
8051                    match self.peek() {
8052                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
8053                            self.advance();
8054                        }
8055                        other => {
8056                            return Err(self.err(alloc::format!(
8057                                "expected THEN after CASE WHEN <expr>, got {other:?}"
8058                            )));
8059                        }
8060                    }
8061                    let value = self.parse_expr(0)?;
8062                    branches.push((cond, value));
8063                }
8064                _ => break,
8065            }
8066        }
8067        if branches.is_empty() {
8068            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
8069        }
8070        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
8071        {
8072            self.advance();
8073            Some(Box::new(self.parse_expr(0)?))
8074        } else {
8075            None
8076        };
8077        match self.peek() {
8078            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
8079                self.advance();
8080            }
8081            other => {
8082                return Err(self.err(alloc::format!(
8083                    "expected END to close CASE expression, got {other:?}"
8084                )));
8085            }
8086        }
8087        Ok(Expr::Case {
8088            operand,
8089            branches,
8090            else_branch,
8091        })
8092    }
8093
8094    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
8095        if !matches!(self.peek(), Token::LParen) {
8096            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
8097        }
8098        self.advance();
8099        let inner = self.parse_select_stmt()?;
8100        if !matches!(self.peek(), Token::RParen) {
8101            return Err(self.err(format!(
8102                "expected ')' after EXISTS-subquery, got {:?}",
8103                self.peek()
8104            )));
8105        }
8106        self.advance();
8107        let Statement::Select(s) = inner else {
8108            unreachable!("parse_select_stmt returns Select")
8109        };
8110        Ok(Expr::Exists {
8111            subquery: Box::new(s),
8112            negated,
8113        })
8114    }
8115
8116    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8117        self.advance(); // IN
8118        if !matches!(self.peek(), Token::LParen) {
8119            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
8120        }
8121        self.advance();
8122        // v4.10: `IN (SELECT ...)` — subquery branch.
8123        if matches!(self.peek(), Token::Select) {
8124            let inner = self.parse_select_stmt()?;
8125            if !matches!(self.peek(), Token::RParen) {
8126                return Err(self.err(format!(
8127                    "expected ')' after IN-subquery, got {:?}",
8128                    self.peek()
8129                )));
8130            }
8131            self.advance();
8132            let Statement::Select(s) = inner else {
8133                unreachable!("parse_select_stmt always returns Statement::Select")
8134            };
8135            return Ok(Expr::InSubquery {
8136                expr: Box::new(expr),
8137                subquery: Box::new(s),
8138                negated,
8139            });
8140        }
8141        let mut elements = Vec::new();
8142        if !matches!(self.peek(), Token::RParen) {
8143            loop {
8144                elements.push(self.parse_expr(0)?);
8145                match self.peek() {
8146                    Token::Comma => {
8147                        self.advance();
8148                    }
8149                    Token::RParen => break,
8150                    other => {
8151                        return Err(
8152                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
8153                        );
8154                    }
8155                }
8156            }
8157        }
8158        self.advance(); // ')'
8159        let target = Box::new(expr);
8160        let combined = if elements.is_empty() {
8161            Expr::Literal(Literal::Bool(false))
8162        } else {
8163            let mut iter = elements.into_iter();
8164            let first = iter.next().unwrap();
8165            let mut acc = Expr::Binary {
8166                lhs: target.clone(),
8167                op: BinOp::Eq,
8168                rhs: Box::new(first),
8169            };
8170            for elt in iter {
8171                acc = Expr::Binary {
8172                    lhs: Box::new(acc),
8173                    op: BinOp::Or,
8174                    rhs: Box::new(Expr::Binary {
8175                        lhs: target.clone(),
8176                        op: BinOp::Eq,
8177                        rhs: Box::new(elt),
8178                    }),
8179                };
8180            }
8181            acc
8182        };
8183        Ok(maybe_not(combined, negated))
8184    }
8185
8186    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
8187    /// already consumed by the caller. Elements must be numeric literals
8188    /// (with optional unary `-`); any compound expression is rejected at
8189    /// parse time so the runtime never needs to evaluate inside a vector.
8190    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
8191    /// has already consumed the `EXTRACT` token before calling us —
8192    /// we pick up at the opening `(`.
8193    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
8194    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
8195    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
8196    /// per-column OR-fold of
8197    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8198    /// term)` so the existing FTS evaluator handles semantics.
8199    ///
8200    /// The mode modifier is accepted-and-ignored at v7.17 — all
8201    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
8202    /// mode operators (`+foo -bar`) would need their own parser
8203    /// (Phase 2.2c); customers who hit them today already get a
8204    /// correct lexeme-match against the bare term, only without
8205    /// the +/- precedence the customer asked for.
8206    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
8207        // Already at `MATCH`-consumed position; the dispatcher
8208        // confirmed the next token is `(`.
8209        if !matches!(self.peek(), Token::LParen) {
8210            return Err(self.err(alloc::format!(
8211                "expected '(' after MATCH, got {:?}",
8212                self.peek()
8213            )));
8214        }
8215        self.advance();
8216        let mut cols: Vec<Expr> = Vec::new();
8217        loop {
8218            cols.push(self.parse_expr(0)?);
8219            match self.peek() {
8220                Token::Comma => {
8221                    self.advance();
8222                }
8223                Token::RParen => break,
8224                other => {
8225                    return Err(self.err(alloc::format!(
8226                        "expected ',' or ')' in MATCH column list, got {other:?}"
8227                    )));
8228                }
8229            }
8230        }
8231        self.advance(); // ')'
8232        // Expect AGAINST.
8233        match self.peek() {
8234            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
8235                self.advance();
8236            }
8237            other => {
8238                return Err(self.err(alloc::format!(
8239                    "expected AGAINST after MATCH column list, got {other:?}"
8240                )));
8241            }
8242        }
8243        if !matches!(self.peek(), Token::LParen) {
8244            return Err(self.err(alloc::format!(
8245                "expected '(' after AGAINST, got {:?}",
8246                self.peek()
8247            )));
8248        }
8249        self.advance();
8250        // Read AGAINST's argument as a single primary token —
8251        // string literal, placeholder, or column-ref ident. We
8252        // can't call `parse_expr` / `parse_unary` here because
8253        // the postfix chain inside `parse_atom` would greedily
8254        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
8255        // and fail at "expected '(' after IN". Customers always
8256        // write a literal or bound parameter in AGAINST, so this
8257        // restriction is non-blocking; the error path explains
8258        // the limit if a more complex expression shows up.
8259        let term = match self.advance() {
8260            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
8261            Token::Placeholder(n) => Expr::Placeholder(n),
8262            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
8263                qualifier: None,
8264                name: s,
8265            }),
8266            other => {
8267                return Err(self.err(alloc::format!(
8268                    "MATCH ... AGAINST(<term>) expects a string literal, \
8269                     bound parameter, or column ref, got {other:?}"
8270                )));
8271            }
8272        };
8273        // Optional mode tail — accept-and-ignore at v7.17:
8274        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
8275        //   IN BOOLEAN MODE
8276        //   WITH QUERY EXPANSION
8277        loop {
8278            match self.peek() {
8279                // IN lexes as a reserved Token::In, not an ident,
8280                // so it gets its own arm.
8281                Token::In => {
8282                    self.advance();
8283                }
8284                Token::Ident(s) | Token::QuotedIdent(s)
8285                    if s.eq_ignore_ascii_case("natural")
8286                        || s.eq_ignore_ascii_case("language")
8287                        || s.eq_ignore_ascii_case("boolean")
8288                        || s.eq_ignore_ascii_case("mode")
8289                        || s.eq_ignore_ascii_case("with")
8290                        || s.eq_ignore_ascii_case("query")
8291                        || s.eq_ignore_ascii_case("expansion") =>
8292                {
8293                    self.advance();
8294                }
8295                _ => break,
8296            }
8297        }
8298        if !matches!(self.peek(), Token::RParen) {
8299            return Err(self.err(alloc::format!(
8300                "expected ')' to close AGAINST, got {:?}",
8301                self.peek()
8302            )));
8303        }
8304        self.advance();
8305        // Build per-column `to_tsvector('simple', col) @@
8306        // plainto_tsquery('simple', term)` and OR-fold.
8307        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
8308        let plainto = Expr::FunctionCall {
8309            name: String::from("plainto_tsquery"),
8310            args: alloc::vec![simple_lit(), term.clone()],
8311        };
8312        let mut folded: Option<Expr> = None;
8313        for col in cols {
8314            let to_tsv = Expr::FunctionCall {
8315                name: String::from("to_tsvector"),
8316                args: alloc::vec![simple_lit(), col],
8317            };
8318            let leaf = Expr::Binary {
8319                lhs: Box::new(to_tsv),
8320                op: crate::ast::BinOp::TsMatch,
8321                rhs: Box::new(plainto.clone()),
8322            };
8323            folded = Some(match folded {
8324                None => leaf,
8325                Some(prev) => Expr::Binary {
8326                    lhs: Box::new(prev),
8327                    op: crate::ast::BinOp::Or,
8328                    rhs: Box::new(leaf),
8329                },
8330            });
8331        }
8332        match folded {
8333            Some(e) => Ok(e),
8334            None => Err(self.err(String::from(
8335                "MATCH(...) AGAINST(...) requires at least one column",
8336            ))),
8337        }
8338    }
8339
8340    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
8341        if !matches!(self.peek(), Token::LParen) {
8342            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
8343        }
8344        self.advance();
8345        let field_name = self.expect_ident_like()?;
8346        let field = match field_name.to_ascii_lowercase().as_str() {
8347            "year" => ExtractField::Year,
8348            "month" => ExtractField::Month,
8349            "day" => ExtractField::Day,
8350            "hour" => ExtractField::Hour,
8351            "minute" => ExtractField::Minute,
8352            "second" => ExtractField::Second,
8353            "microsecond" | "microseconds" => ExtractField::Microsecond,
8354            "epoch" => ExtractField::Epoch,
8355            other => {
8356                return Err(self.err(format!(
8357                    "unknown EXTRACT field {other:?}; \
8358                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
8359                )));
8360            }
8361        };
8362        if !matches!(self.peek(), Token::From) {
8363            return Err(self.err(format!(
8364                "expected FROM after EXTRACT field, got {:?}",
8365                self.peek()
8366            )));
8367        }
8368        self.advance();
8369        let source = self.parse_expr(0)?;
8370        if !matches!(self.peek(), Token::RParen) {
8371            return Err(self.err(format!(
8372                "expected ')' to close EXTRACT, got {:?}",
8373                self.peek()
8374            )));
8375        }
8376        self.advance();
8377        Ok(Expr::Extract {
8378            field,
8379            source: Box::new(source),
8380        })
8381    }
8382
8383    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
8384    /// is already consumed; we expect a single string literal next and
8385    /// resolve it into `Literal::Interval` at parse time so the engine
8386    /// never has to re-tokenise inside the string.
8387    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
8388        let tok = self.advance();
8389        let Token::String(text) = tok else {
8390            return Err(self.err(format!(
8391                "expected string literal after INTERVAL, got {tok:?}"
8392            )));
8393        };
8394        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
8395            message: format!(
8396                "cannot parse INTERVAL {text:?}; \
8397                     expected `<n> <unit> [<n> <unit> ...]` with units \
8398                     microsecond[s], millisecond[s], second[s], minute[s], \
8399                     hour[s], day[s], week[s], month[s], year[s]"
8400            ),
8401            token_pos: self.pos.saturating_sub(1),
8402        })?;
8403        Ok(Expr::Literal(Literal::Interval {
8404            months,
8405            micros,
8406            text,
8407        }))
8408    }
8409
8410    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
8411        let mut elems = Vec::new();
8412        if matches!(self.peek(), Token::RBracket) {
8413            self.advance();
8414            return Ok(Expr::Literal(Literal::Vector(elems)));
8415        }
8416        loop {
8417            let e = self.parse_expr(0)?;
8418            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
8419                message: format!("vector element must be a numeric literal, got {e:?}"),
8420                token_pos: self.pos,
8421            })?;
8422            elems.push(x);
8423            match self.peek() {
8424                Token::Comma => {
8425                    self.advance();
8426                }
8427                Token::RBracket => {
8428                    self.advance();
8429                    break;
8430                }
8431                other => {
8432                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
8433                }
8434            }
8435        }
8436        Ok(Expr::Literal(Literal::Vector(elems)))
8437    }
8438
8439    /// Atom that started with an identifier: could be `t.col`, `col`, or
8440    /// `func(arg, ...)`. Detect each shape by looking at the next token.
8441    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
8442    /// [, ...])`. Caller has already consumed `OVER`. Either clause
8443    /// is optional; an empty `()` is also legal (PG semantics).
8444    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
8445    /// modifier between `name(args)` and `OVER (...)`. Default is
8446    /// `Respect`. Unrecognised idents leave the stream unchanged.
8447    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
8448        let Token::Ident(s) = self.peek().clone() else {
8449            return NullTreatment::Respect;
8450        };
8451        let is_ignore = s.eq_ignore_ascii_case("ignore");
8452        let is_respect = s.eq_ignore_ascii_case("respect");
8453        if !is_ignore && !is_respect {
8454            return NullTreatment::Respect;
8455        }
8456        // Lookahead for NULLS — only consume both tokens together.
8457        // pos+1 must hold a "nulls" ident.
8458        if self.pos + 1 < self.tokens.len()
8459            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
8460            && s2.eq_ignore_ascii_case("nulls")
8461        {
8462            self.advance();
8463            self.advance();
8464            return if is_ignore {
8465                NullTreatment::Ignore
8466            } else {
8467                NullTreatment::Respect
8468            };
8469        }
8470        NullTreatment::Respect
8471    }
8472
8473    /// No frame clause is supported.
8474    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
8475    fn parse_over_clause(
8476        &mut self,
8477    ) -> Result<(Vec<Expr>, Vec<(Expr, bool)>, Option<WindowFrame>), ParseError> {
8478        if !matches!(self.peek(), Token::LParen) {
8479            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
8480        }
8481        self.advance();
8482        let mut partition_by = Vec::new();
8483        let mut order_by = Vec::new();
8484        // PARTITION BY ?
8485        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8486            && s.eq_ignore_ascii_case("partition")
8487        {
8488            self.advance();
8489            if !matches!(self.peek(), Token::By) {
8490                return Err(self.err(format!(
8491                    "expected BY after PARTITION, got {:?}",
8492                    self.peek()
8493                )));
8494            }
8495            self.advance();
8496            loop {
8497                partition_by.push(self.parse_expr(0)?);
8498                if matches!(self.peek(), Token::Comma) {
8499                    self.advance();
8500                    continue;
8501                }
8502                break;
8503            }
8504        }
8505        // ORDER BY ?
8506        if matches!(self.peek(), Token::Order) {
8507            self.advance();
8508            if !matches!(self.peek(), Token::By) {
8509                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
8510            }
8511            self.advance();
8512            loop {
8513                let e = self.parse_expr(0)?;
8514                let desc = if matches!(self.peek(), Token::Desc) {
8515                    self.advance();
8516                    true
8517                } else if matches!(self.peek(), Token::Asc) {
8518                    self.advance();
8519                    false
8520                } else {
8521                    false
8522                };
8523                order_by.push((e, desc));
8524                if matches!(self.peek(), Token::Comma) {
8525                    self.advance();
8526                    continue;
8527                }
8528                break;
8529            }
8530        }
8531        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
8532        // Both keywords come through the lexer as identifiers; match
8533        // case-insensitively.
8534        let mut frame: Option<WindowFrame> = None;
8535        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8536            let kind = if s.eq_ignore_ascii_case("rows") {
8537                Some(FrameKind::Rows)
8538            } else if s.eq_ignore_ascii_case("range") {
8539                Some(FrameKind::Range)
8540            } else {
8541                None
8542            };
8543            if let Some(kind) = kind {
8544                self.advance();
8545                frame = Some(self.parse_frame_tail(kind)?);
8546            }
8547        }
8548        if !matches!(self.peek(), Token::RParen) {
8549            return Err(self.err(format!(
8550                "expected ')' to close OVER clause, got {:?}",
8551                self.peek()
8552            )));
8553        }
8554        self.advance();
8555        Ok((partition_by, order_by, frame))
8556    }
8557
8558    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
8559    /// or `RANGE` keyword was just consumed. Accepts both
8560    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
8561    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
8562    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
8563    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
8564        if matches!(self.peek(), Token::Between) {
8565            self.advance();
8566            let start = self.parse_frame_bound()?;
8567            if !matches!(self.peek(), Token::And) {
8568                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
8569            }
8570            self.advance();
8571            let end = self.parse_frame_bound()?;
8572            Ok(WindowFrame {
8573                kind,
8574                start,
8575                end: Some(end),
8576            })
8577        } else {
8578            let start = self.parse_frame_bound()?;
8579            Ok(WindowFrame {
8580                kind,
8581                start,
8582                end: None,
8583            })
8584        }
8585    }
8586
8587    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
8588    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
8589    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
8590        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
8591        if let Token::Integer(n) = *self.peek() {
8592            self.advance();
8593            let n: u64 = u64::try_from(n).map_err(|_| {
8594                self.err(format!(
8595                    "invalid frame offset {n} — expected non-negative integer"
8596                ))
8597            })?;
8598            let dir = self.expect_ident_like()?;
8599            return if dir.eq_ignore_ascii_case("preceding") {
8600                Ok(FrameBound::OffsetPreceding(n))
8601            } else if dir.eq_ignore_ascii_case("following") {
8602                Ok(FrameBound::OffsetFollowing(n))
8603            } else {
8604                Err(self.err(format!(
8605                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
8606                )))
8607            };
8608        }
8609        let first = self.expect_ident_like()?;
8610        if first.eq_ignore_ascii_case("unbounded") {
8611            let dir = self.expect_ident_like()?;
8612            return if dir.eq_ignore_ascii_case("preceding") {
8613                Ok(FrameBound::UnboundedPreceding)
8614            } else if dir.eq_ignore_ascii_case("following") {
8615                Ok(FrameBound::UnboundedFollowing)
8616            } else {
8617                Err(self.err(format!(
8618                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
8619                )))
8620            };
8621        }
8622        if first.eq_ignore_ascii_case("current") {
8623            let row = self.expect_ident_like()?;
8624            if !row.eq_ignore_ascii_case("row") {
8625                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
8626            }
8627            return Ok(FrameBound::CurrentRow);
8628        }
8629        Err(self.err(format!(
8630            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
8631        )))
8632    }
8633
8634    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
8635        if matches!(self.peek(), Token::Dot) {
8636            self.advance();
8637            let name = self.expect_ident_like()?;
8638            // v7.14.0 — schema-qualified function call
8639            // `<schema>.<fn>(args)`. PG dumps emit
8640            // `pg_catalog.set_config(...)` in the preamble. SPG
8641            // is single-namespace: drop the schema prefix and
8642            // route the dispatch on the bare function name.
8643            if matches!(self.peek(), Token::LParen) {
8644                return self.finish_ident_atom(name);
8645            }
8646            return Ok(Expr::Column(ColumnName {
8647                qualifier: Some(first),
8648                name,
8649            }));
8650        }
8651        if matches!(self.peek(), Token::LParen) {
8652            self.advance();
8653            // `COUNT(*)` — special-cased here because `*` isn't a normal
8654            // expression token. Lower-case match on `first` since the lexer
8655            // folds identifiers.
8656            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
8657                self.advance();
8658                if !matches!(self.peek(), Token::RParen) {
8659                    return Err(self.err(format!(
8660                        "expected ')' after COUNT(*), got {:?}",
8661                        self.peek()
8662                    )));
8663                }
8664                self.advance();
8665                // v4.12: COUNT(*) OVER (...) — same window tail.
8666                let null_treatment = self.parse_null_treatment_modifier();
8667                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8668                    && s.eq_ignore_ascii_case("over")
8669                {
8670                    self.advance();
8671                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
8672                    return Ok(Expr::WindowFunction {
8673                        name: "count_star".into(),
8674                        args: Vec::new(),
8675                        partition_by,
8676                        order_by,
8677                        frame,
8678                        null_treatment,
8679                    });
8680                }
8681                return Ok(Expr::FunctionCall {
8682                    name: "count_star".into(),
8683                    args: Vec::new(),
8684                });
8685            }
8686            // Function call. PG-style: zero-or-more comma-separated args.
8687            let mut args = Vec::new();
8688            if !matches!(self.peek(), Token::RParen) {
8689                loop {
8690                    args.push(self.parse_expr(0)?);
8691                    match self.peek() {
8692                        Token::Comma => {
8693                            self.advance();
8694                        }
8695                        Token::RParen => break,
8696                        other => {
8697                            return Err(self.err(format!(
8698                                "expected ',' or ')' in function args, got {other:?}"
8699                            )));
8700                        }
8701                    }
8702                }
8703            }
8704            self.advance(); // consume ')'
8705            // v4.12: window-function tail — `name(args) OVER (...)`.
8706            // Promotes the just-parsed FunctionCall into a
8707            // WindowFunction node carrying partition + order.
8708            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
8709            // / `RESPECT NULLS OVER (...)` between the closing paren
8710            // and `OVER`.
8711            let null_treatment = self.parse_null_treatment_modifier();
8712            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8713                && s.eq_ignore_ascii_case("over")
8714            {
8715                self.advance();
8716                let (partition_by, order_by, frame) = self.parse_over_clause()?;
8717                return Ok(Expr::WindowFunction {
8718                    name: first,
8719                    args,
8720                    partition_by,
8721                    order_by,
8722                    frame,
8723                    null_treatment,
8724                });
8725            }
8726            return Ok(Expr::FunctionCall { name: first, args });
8727        }
8728        // v7.9.20 — SQL-standard parenless keyword expressions
8729        // (PG treats these as functions called without parens).
8730        // Resolve to a synthetic FunctionCall so the engine's
8731        // eval path reuses the existing function-call routing.
8732        // mailrs G3.
8733        let lc = first.to_ascii_lowercase();
8734        if matches!(
8735            lc.as_str(),
8736            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
8737        ) {
8738            return Ok(Expr::FunctionCall {
8739                name: lc,
8740                args: Vec::new(),
8741            });
8742        }
8743        Ok(Expr::Column(ColumnName {
8744            qualifier: None,
8745            name: first,
8746        }))
8747    }
8748}
8749
8750/// v6.8.2 — walk an expression tree and return the first column
8751/// reference's bare name. Used by `parse_create_index_stmt_after_create`
8752/// to derive `CreateIndexStatement.column` from an expression
8753/// key (so downstream planner code resolving a primary column
8754/// position keeps working with expression indexes). Returns
8755/// `None` when the expression has no column ref at all — caller
8756/// surfaces that as a parse error.
8757fn extract_first_column(expr: &Expr) -> Option<String> {
8758    match expr {
8759        Expr::Column(cn) => Some(cn.name.clone()),
8760        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
8761        Expr::Binary { lhs, rhs, .. } => {
8762            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
8763        }
8764        Expr::Unary { expr: e, .. } => extract_first_column(e),
8765        _ => None,
8766    }
8767}
8768
8769fn maybe_not(expr: Expr, negated: bool) -> Expr {
8770    if negated {
8771        Expr::Unary {
8772            op: UnOp::Not,
8773            expr: Box::new(expr),
8774        }
8775    } else {
8776        expr
8777    }
8778}
8779
8780fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
8781    let pair = match tok {
8782        Token::Or => (BinOp::Or, 1),
8783        Token::And => (BinOp::And, 2),
8784        Token::Eq => (BinOp::Eq, 4),
8785        Token::NotEq => (BinOp::NotEq, 4),
8786        Token::Lt => (BinOp::Lt, 4),
8787        Token::LtEq => (BinOp::LtEq, 4),
8788        Token::Gt => (BinOp::Gt, 4),
8789        Token::GtEq => (BinOp::GtEq, 4),
8790        // pgvector distance ops all sit on the same rung — tighter than
8791        // comparisons (4) so `col <-> v < threshold` parses correctly.
8792        Token::L2Distance => (BinOp::L2Distance, 5),
8793        Token::InnerProduct => (BinOp::InnerProduct, 5),
8794        Token::CosineDistance => (BinOp::CosineDistance, 5),
8795        Token::Plus => (BinOp::Add, 6),
8796        Token::Minus => (BinOp::Sub, 6),
8797        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
8798        // by the same level as binary additive arithmetic).
8799        Token::Concat => (BinOp::Concat, 6),
8800        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
8801        // all "other" operators between additive and comparison, so
8802        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
8803        //
8804        // Known divergence (the same one `||` has carried since v1):
8805        // SPG's rung 6 TIES with `+ -`, while PG binds generic
8806        // operators LOOSER than additive — `a & b + 1` is
8807        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
8808        // mixed bitwise/arithmetic. Keeping every generic operator
8809        // on one shared rung is deliberate: splitting bitwise off
8810        // would fix that case but skew `a || b & c`, which PG
8811        // left-folds at a single level.
8812        Token::Pipe => (BinOp::BitOr, 6),
8813        Token::Amp => (BinOp::BitAnd, 6),
8814        Token::Star => (BinOp::Mul, 7),
8815        Token::Slash => (BinOp::Div, 7),
8816        // v4.14: JSON path ops bind tighter than comparisons (4)
8817        // and additive (6) so `doc->'k' = 'v'` parses correctly.
8818        // Same rung as the multiplicative ops.
8819        Token::JsonGet => (BinOp::JsonGet, 7),
8820        Token::JsonGetText => (BinOp::JsonGetText, 7),
8821        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
8822        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
8823        Token::JsonContains => (BinOp::JsonContains, 7),
8824        // v7.12.2 — `@@` binds at the comparison rung (looser than
8825        // arithmetic, tighter than AND / OR). PG places `@@` at
8826        // the same precedence as `=` / `<`, so we follow.
8827        Token::TsMatch => (BinOp::TsMatch, 4),
8828        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
8829        // PG places these at the comparison rung (same level as `=`),
8830        // so we follow.
8831        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
8832        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
8833        Token::InetContains => (BinOp::InetContains, 4),
8834        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
8835        Token::InetOverlap => (BinOp::InetOverlap, 4),
8836        _ => return None,
8837    };
8838    Some(pair)
8839}
8840
8841#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
8842// `as f32` here is intentional: vector elements widen / narrow into f32 on
8843// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
8844// past ~15 decimal digits — both are acceptable for a fixed-precision
8845// pgvector column.
8846/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
8847/// implicit table alias and break trailing clauses. WITH lands
8848/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
8849/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
8850/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
8851/// / VALUES / FOR / LATERAL — all of which would otherwise be
8852/// silently swallowed by `parse_optional_alias`.
8853fn is_alias_stopword(s: &str) -> bool {
8854    matches!(
8855        s.to_ascii_lowercase().as_str(),
8856        "with"
8857            | "on"
8858            | "where"
8859            | "having"
8860            | "group"
8861            | "order"
8862            | "limit"
8863            | "offset"
8864            | "union"
8865            | "except"
8866            | "intersect"
8867            | "returning"
8868            | "set"
8869            | "values"
8870            | "for"
8871            | "lateral"
8872            | "left"
8873            | "right"
8874            | "inner"
8875            | "outer"
8876            | "full"
8877            | "cross"
8878            | "join"
8879            | "natural"
8880            | "using"
8881            | "fetch"
8882    )
8883}
8884
8885fn extract_numeric_literal(e: &Expr) -> Option<f32> {
8886    match e {
8887        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
8888        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
8889        Expr::Unary {
8890            op: UnOp::Neg,
8891            expr,
8892        } => extract_numeric_literal(expr).map(|x| -x),
8893        _ => None,
8894    }
8895}
8896
8897/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
8898/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
8899/// negative. Returns `None` if any pair fails to parse or no pair is found.
8900///
8901/// Recognised units (case-insensitive, optional trailing `s`):
8902/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
8903/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
8904pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
8905    let parts: Vec<&str> = s.split_whitespace().collect();
8906    if parts.is_empty() || !parts.len().is_multiple_of(2) {
8907        return None;
8908    }
8909    let mut months: i32 = 0;
8910    let mut micros: i64 = 0;
8911    let mut i = 0;
8912    while i < parts.len() {
8913        let n: i64 = parts[i].parse().ok()?;
8914        let unit = parts[i + 1].to_ascii_lowercase();
8915        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
8916        match unit_stripped {
8917            "microsecond" => micros = micros.checked_add(n)?,
8918            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
8919            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
8920            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
8921            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
8922            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
8923            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
8924            "month" => {
8925                let n32 = i32::try_from(n).ok()?;
8926                months = months.checked_add(n32)?;
8927            }
8928            "year" => {
8929                let n32 = i32::try_from(n).ok()?;
8930                months = months.checked_add(n32.checked_mul(12)?)?;
8931            }
8932            _ => return None,
8933        }
8934        i += 2;
8935    }
8936    Some((months, micros))
8937}
8938
8939/// v7.12.4 — map a bare type-name identifier (the form that
8940/// appears in a function arg list or RETURNS clause) to a
8941/// [`ColumnTypeName`]. Returns `None` for unknown / extension
8942/// types so the caller can preserve them as
8943/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
8944///
8945/// Subset of the full column-type grammar — we deliberately
8946/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
8947/// here because function-arg types in v7.12.4 are mostly the
8948/// bare form (`text`, `int`, `bytea`, …).
8949fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
8950    Some(match ident.to_ascii_lowercase().as_str() {
8951        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
8952        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
8953        "bigint" => ColumnTypeName::BigInt,
8954        "float" | "double" | "real" => ColumnTypeName::Float,
8955        "text" => ColumnTypeName::Text,
8956        "bool" | "boolean" => ColumnTypeName::Bool,
8957        "date" => ColumnTypeName::Date,
8958        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
8959        "timestamptz" => ColumnTypeName::Timestamptz,
8960        "json" => ColumnTypeName::Json,
8961        "jsonb" => ColumnTypeName::Jsonb,
8962        "bytea" | "bytes" => ColumnTypeName::Bytes,
8963        "tsvector" => ColumnTypeName::TsVector,
8964        "tsquery" => ColumnTypeName::TsQuery,
8965        "uuid" => ColumnTypeName::Uuid,
8966        "time" => ColumnTypeName::Time,
8967        "year" => ColumnTypeName::Year,
8968        "timetz" => ColumnTypeName::TimeTz,
8969        "money" => ColumnTypeName::Money,
8970        _ => return None,
8971    })
8972}
8973
8974/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
8975/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
8976///
8977/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
8978/// / embedded SQL land in v7.12.5+):
8979///
8980/// ```text
8981///   body          := [ws] block [ws]
8982///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
8983///   stmt          := assign | return
8984///   assign        := assign_target := expr
8985///   assign_target := ( NEW | OLD ) . ident | ident
8986///   return        := RETURN ( NEW | OLD | NULL | expr )
8987/// ```
8988///
8989/// `expr` is parsed by recursing into the regular `Parser` — so a
8990/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
8991/// NEW.subject || ' ' || NEW.sender)` body shape works without
8992/// the body parser knowing what `to_tsvector` is.
8993///
8994/// Errors here cause the caller to fall back to
8995/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
8996/// successful, but the executor will refuse to invoke the
8997/// function with an "unparseable body" error.
8998/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
8999/// from the crate root as `spg_sql::parse_function_body`.
9000pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9001    parse_plpgsql_body(body)
9002}
9003
9004fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9005    // Use the regular lexer on the body text. The trailing
9006    // `END;` may or may not have a semicolon; the lexer treats
9007    // both forms identically.
9008    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
9009        message: alloc::format!("plpgsql body lex error: {e}"),
9010        token_pos: 0,
9011    })?;
9012    let mut parser = Parser::new(tokens);
9013    parser.parse_plpgsql_block()
9014}
9015
9016#[cfg(test)]
9017mod tests {
9018    use super::*;
9019    use alloc::string::ToString;
9020
9021    fn parse(s: &str) -> Statement {
9022        parse_statement(s).expect("parse ok")
9023    }
9024
9025    fn lit_int(n: i64) -> Expr {
9026        Expr::Literal(Literal::Integer(n))
9027    }
9028
9029    fn col(name: &str) -> Expr {
9030        Expr::Column(ColumnName {
9031            qualifier: None,
9032            name: name.into(),
9033        })
9034    }
9035
9036    #[test]
9037    fn select_single_integer() {
9038        let s = parse("SELECT 1");
9039        let Statement::Select(s) = s else {
9040            panic!("expected SELECT")
9041        };
9042        assert_eq!(s.items.len(), 1);
9043        assert!(s.from.is_none());
9044        assert!(s.where_.is_none());
9045    }
9046
9047    #[test]
9048    fn select_multiple_literal_kinds() {
9049        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
9050        let Statement::Select(s) = s else {
9051            panic!("expected SELECT")
9052        };
9053        assert_eq!(s.items.len(), 5);
9054    }
9055
9056    #[test]
9057    fn select_wildcard_from_table() {
9058        let s = parse("SELECT * FROM users");
9059        let Statement::Select(s) = s else {
9060            panic!("expected SELECT")
9061        };
9062        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
9063        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
9064    }
9065
9066    #[test]
9067    fn select_with_table_alias() {
9068        let s = parse("SELECT * FROM users AS u");
9069        let Statement::Select(s) = s else {
9070            panic!("expected SELECT")
9071        };
9072        let t = &s.from.as_ref().unwrap().primary;
9073        assert_eq!(t.name, "users");
9074        assert_eq!(t.alias.as_deref(), Some("u"));
9075    }
9076
9077    #[test]
9078    fn select_with_where_eq() {
9079        let s = parse("SELECT a FROM t WHERE a = 1");
9080        let Statement::Select(s) = s else {
9081            panic!("expected SELECT")
9082        };
9083        let w = s.where_.unwrap();
9084        assert_eq!(
9085            w,
9086            Expr::Binary {
9087                lhs: Box::new(col("a")),
9088                op: BinOp::Eq,
9089                rhs: Box::new(lit_int(1)),
9090            }
9091        );
9092    }
9093
9094    #[test]
9095    fn arithmetic_precedence() {
9096        let s = parse("SELECT 1 + 2 * 3");
9097        let Statement::Select(s) = s else {
9098            panic!("expected SELECT")
9099        };
9100        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9101            panic!("wildcard?")
9102        };
9103        assert_eq!(
9104            expr,
9105            &Expr::Binary {
9106                lhs: Box::new(lit_int(1)),
9107                op: BinOp::Add,
9108                rhs: Box::new(Expr::Binary {
9109                    lhs: Box::new(lit_int(2)),
9110                    op: BinOp::Mul,
9111                    rhs: Box::new(lit_int(3)),
9112                }),
9113            }
9114        );
9115    }
9116
9117    #[test]
9118    fn parentheses_override_precedence() {
9119        let s = parse("SELECT (1 + 2) * 3");
9120        let Statement::Select(s) = s else {
9121            panic!("expected SELECT")
9122        };
9123        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9124            panic!()
9125        };
9126        assert_eq!(
9127            expr,
9128            &Expr::Binary {
9129                lhs: Box::new(Expr::Binary {
9130                    lhs: Box::new(lit_int(1)),
9131                    op: BinOp::Add,
9132                    rhs: Box::new(lit_int(2)),
9133                }),
9134                op: BinOp::Mul,
9135                rhs: Box::new(lit_int(3)),
9136            }
9137        );
9138    }
9139
9140    #[test]
9141    fn not_binds_below_comparison() {
9142        // `NOT a = 1` should parse as `NOT (a = 1)`.
9143        let s = parse("SELECT NOT a = 1 FROM t");
9144        let Statement::Select(s) = s else {
9145            panic!("expected SELECT")
9146        };
9147        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9148            panic!()
9149        };
9150        assert_eq!(
9151            expr,
9152            &Expr::Unary {
9153                op: UnOp::Not,
9154                expr: Box::new(Expr::Binary {
9155                    lhs: Box::new(col("a")),
9156                    op: BinOp::Eq,
9157                    rhs: Box::new(lit_int(1)),
9158                }),
9159            }
9160        );
9161    }
9162
9163    #[test]
9164    fn unary_minus_binds_above_multiplication() {
9165        // `-a * 2` should be `(-a) * 2`.
9166        let s = parse("SELECT -a * 2 FROM t");
9167        let Statement::Select(s) = s else {
9168            panic!("expected SELECT")
9169        };
9170        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9171            panic!()
9172        };
9173        assert_eq!(
9174            expr,
9175            &Expr::Binary {
9176                lhs: Box::new(Expr::Unary {
9177                    op: UnOp::Neg,
9178                    expr: Box::new(col("a")),
9179                }),
9180                op: BinOp::Mul,
9181                rhs: Box::new(lit_int(2)),
9182            }
9183        );
9184    }
9185
9186    #[test]
9187    fn qualified_column() {
9188        let s = parse("SELECT t.col FROM t");
9189        let Statement::Select(s) = s else {
9190            panic!("expected SELECT")
9191        };
9192        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9193            panic!()
9194        };
9195        assert_eq!(
9196            expr,
9197            &Expr::Column(ColumnName {
9198                qualifier: Some("t".into()),
9199                name: "col".into()
9200            })
9201        );
9202    }
9203
9204    #[test]
9205    fn select_item_alias_with_as() {
9206        let s = parse("SELECT a AS y FROM t");
9207        let Statement::Select(s) = s else {
9208            panic!("expected SELECT")
9209        };
9210        let SelectItem::Expr { alias, .. } = &s.items[0] else {
9211            panic!()
9212        };
9213        assert_eq!(alias.as_deref(), Some("y"));
9214    }
9215
9216    #[test]
9217    fn trailing_semicolon_accepted() {
9218        let s = parse("SELECT 1;");
9219        let Statement::Select(s) = s else {
9220            panic!("expected SELECT")
9221        };
9222        assert_eq!(s.items.len(), 1);
9223    }
9224
9225    #[test]
9226    fn boolean_chain_with_and_or_not() {
9227        // (NOT a) OR (b AND (NOT c))
9228        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
9229        let Statement::Select(s) = s else {
9230            panic!("expected SELECT")
9231        };
9232        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9233            panic!()
9234        };
9235        let expected = Expr::Binary {
9236            lhs: Box::new(Expr::Unary {
9237                op: UnOp::Not,
9238                expr: Box::new(col("a")),
9239            }),
9240            op: BinOp::Or,
9241            rhs: Box::new(Expr::Binary {
9242                lhs: Box::new(col("b")),
9243                op: BinOp::And,
9244                rhs: Box::new(Expr::Unary {
9245                    op: UnOp::Not,
9246                    expr: Box::new(col("c")),
9247                }),
9248            }),
9249        };
9250        assert_eq!(expr, &expected);
9251    }
9252
9253    #[test]
9254    fn empty_input_errors() {
9255        // v7.14.0 — pg_dump preambles emit several comment-only
9256        // / blank-line statements that collapse to Statement::
9257        // Empty rather than a parse error. The old "SELECT in
9258        // message" assertion is stale; verify the new contract:
9259        // empty / whitespace / comment-only input parses to
9260        // Statement::Empty.
9261        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
9262        assert!(matches!(
9263            parse_statement("  \n\t ").unwrap(),
9264            Statement::Empty
9265        ));
9266        // Sanity: malformed-but-non-empty still errors.
9267        assert!(parse_statement("SELECT FROM WHERE").is_err());
9268    }
9269
9270    #[test]
9271    fn unmatched_paren_errors() {
9272        assert!(parse_statement("SELECT (1 + 2").is_err());
9273    }
9274
9275    #[test]
9276    fn display_round_trip_simple_select() {
9277        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
9278        let text = original.to_string();
9279        let again = parse_statement(&text).expect("re-parse");
9280        assert_eq!(original, again);
9281    }
9282
9283    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
9284
9285    #[test]
9286    fn create_table_single_column() {
9287        let s = parse("CREATE TABLE foo (a INT)");
9288        let Statement::CreateTable(c) = s else {
9289            panic!("expected CreateTable")
9290        };
9291        assert_eq!(c.name, "foo");
9292        assert_eq!(c.columns.len(), 1);
9293        assert_eq!(c.columns[0].name, "a");
9294        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9295        assert!(c.columns[0].nullable);
9296    }
9297
9298    #[test]
9299    fn create_table_multi_column_with_not_null_mix() {
9300        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
9301        let Statement::CreateTable(c) = s else {
9302            panic!()
9303        };
9304        assert_eq!(c.columns.len(), 4);
9305        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9306        assert!(!c.columns[0].nullable);
9307        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
9308        assert!(c.columns[1].nullable);
9309        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
9310        assert!(!c.columns[2].nullable);
9311        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
9312    }
9313
9314    #[test]
9315    fn create_table_bigint_supported() {
9316        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
9317        let Statement::CreateTable(c) = s else {
9318            panic!()
9319        };
9320        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
9321    }
9322
9323    #[test]
9324    fn create_table_vector_default_is_f32() {
9325        let s = parse("CREATE TABLE t (v VECTOR(128))");
9326        let Statement::CreateTable(c) = s else {
9327            panic!()
9328        };
9329        assert_eq!(
9330            c.columns[0].ty,
9331            ColumnTypeName::Vector {
9332                dim: 128,
9333                encoding: VecEncoding::F32,
9334            },
9335        );
9336    }
9337
9338    #[test]
9339    fn create_table_vector_using_sq8() {
9340        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
9341        // Case-insensitive on both `USING` and the encoding name.
9342        for sql in [
9343            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
9344            "CREATE TABLE t (v VECTOR(128) using sq8)",
9345        ] {
9346            let s = parse(sql);
9347            let Statement::CreateTable(c) = s else {
9348                panic!()
9349            };
9350            assert_eq!(
9351                c.columns[0].ty,
9352                ColumnTypeName::Vector {
9353                    dim: 128,
9354                    encoding: VecEncoding::Sq8,
9355                },
9356                "{sql}",
9357            );
9358        }
9359    }
9360
9361    #[test]
9362    fn create_table_vector_using_unknown_errors() {
9363        // v7.16.1 — the inline `USING <encoding>` shape on
9364        // CREATE TABLE column defs was withdrawn before
9365        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
9366        // (col vector_<metric>_ops)`; the parser now rejects
9367        // USING at column-list position with a clearer
9368        // "expected ',' or ')'" message. Test asserts the
9369        // current rejection, not the old "unknown vector
9370        // encoding" string.
9371        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
9372        assert!(
9373            err.message.contains("USING")
9374                || err.message.contains("using")
9375                || err.message.contains("')'")
9376                || err.message.contains("','"),
9377            "expected USING/column-list rejection, got: {}",
9378            err.message
9379        );
9380    }
9381
9382    #[test]
9383    fn vector_using_sq8_display_roundtrips() {
9384        // The Display impl must produce text that re-parses to the
9385        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
9386        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
9387        let Statement::CreateTable(c) = s else {
9388            panic!()
9389        };
9390        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
9391    }
9392
9393    #[test]
9394    fn parser_recognises_placeholders() {
9395        use crate::ast::{Expr, SelectItem, Statement};
9396        // $N in expression position parses as Expr::Placeholder(N).
9397        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
9398        let Statement::Select(sel) = s else { panic!() };
9399        assert!(matches!(
9400            sel.items[0],
9401            SelectItem::Expr {
9402                expr: Expr::Placeholder(1),
9403                alias: None
9404            }
9405        ));
9406        // $2 + 1
9407        let SelectItem::Expr {
9408            expr: Expr::Binary { lhs, rhs, .. },
9409            ..
9410        } = &sel.items[1]
9411        else {
9412            panic!()
9413        };
9414        assert!(matches!(**lhs, Expr::Placeholder(2)));
9415        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
9416        // WHERE x = $3
9417        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
9418            panic!()
9419        };
9420        assert!(matches!(**rhs, Expr::Placeholder(3)));
9421    }
9422
9423    #[test]
9424    fn parser_rejects_dollar_zero() {
9425        // $0 is not valid in PG; the lexer rejects it.
9426        assert!(parse_statement("SELECT $0").is_err());
9427    }
9428
9429    #[test]
9430    fn placeholder_display_roundtrips() {
9431        // The Display impl must produce text that re-lexes to the
9432        // same Placeholder token.
9433        let s = parse("SELECT $42 FROM t");
9434        let printed = s.to_string();
9435        assert!(printed.contains("$42"));
9436        let again = parse(&printed);
9437        assert_eq!(s, again);
9438    }
9439
9440    #[test]
9441    fn alter_index_rebuild_bare() {
9442        use crate::ast::{AlterIndexTarget, Statement};
9443        let s = parse("ALTER INDEX my_idx REBUILD");
9444        let Statement::AlterIndex(a) = s else {
9445            panic!("expected AlterIndex, got {s:?}")
9446        };
9447        assert_eq!(a.name, "my_idx");
9448        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
9449    }
9450
9451    #[test]
9452    fn alter_index_rebuild_with_encoding() {
9453        use crate::ast::{AlterIndexTarget, Statement};
9454        for (sql, want) in [
9455            (
9456                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
9457                VecEncoding::F32,
9458            ),
9459            (
9460                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
9461                VecEncoding::Sq8,
9462            ),
9463            (
9464                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9465                VecEncoding::F16,
9466            ),
9467        ] {
9468            let s = parse(sql);
9469            let Statement::AlterIndex(a) = s else {
9470                panic!("{sql}: expected AlterIndex")
9471            };
9472            assert_eq!(a.name, "my_idx");
9473            assert_eq!(
9474                a.target,
9475                AlterIndexTarget::Rebuild {
9476                    encoding: Some(want)
9477                },
9478                "{sql}"
9479            );
9480        }
9481    }
9482
9483    #[test]
9484    fn alter_index_rebuild_unknown_encoding_errors() {
9485        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
9486        assert!(
9487            err.message.contains("unknown vector encoding"),
9488            "got: {}",
9489            err.message
9490        );
9491    }
9492
9493    #[test]
9494    fn alter_index_rebuild_display_roundtrips() {
9495        for (input, want) in [
9496            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
9497            (
9498                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9499                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9500            ),
9501            (
9502                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9503                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9504            ),
9505        ] {
9506            let s = parse(input);
9507            assert_eq!(s.to_string(), want);
9508        }
9509    }
9510
9511    #[test]
9512    fn create_table_unknown_type_defers_to_engine() {
9513        // v4.9 picked XML as a parse-time "unsupported column
9514        // type" probe. v7.17.0 Phase 1.4 changed the contract:
9515        // an unknown type ident parses as Text + `user_type_ref`
9516        // so CREATE TABLE can resolve user-defined enum / domain
9517        // types — rejection of truly-unknown types moved to the
9518        // engine's catalog lookup.
9519        let stmt = parse_statement("CREATE TABLE x (a xml)").unwrap();
9520        let Statement::CreateTable(t) = stmt else {
9521            panic!("expected CreateTable");
9522        };
9523        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("xml"));
9524    }
9525
9526    #[test]
9527    fn create_table_missing_table_keyword_errors() {
9528        assert!(parse_statement("CREATE x (a INT)").is_err());
9529    }
9530
9531    #[test]
9532    fn insert_single_value() {
9533        let s = parse("INSERT INTO foo VALUES (42)");
9534        let Statement::Insert(i) = s else {
9535            panic!("expected Insert")
9536        };
9537        assert_eq!(i.table, "foo");
9538        assert_eq!(i.rows.len(), 1);
9539        assert_eq!(i.rows[0].len(), 1);
9540        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
9541    }
9542
9543    #[test]
9544    fn insert_multi_value_with_mixed_literals() {
9545        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
9546        let Statement::Insert(i) = s else { panic!() };
9547        assert_eq!(i.rows.len(), 1);
9548        assert_eq!(i.rows[0].len(), 5);
9549    }
9550
9551    #[test]
9552    fn insert_missing_into_errors() {
9553        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
9554    }
9555
9556    #[test]
9557    fn create_table_round_trip() {
9558        let original =
9559            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
9560        let text = original.to_string();
9561        let again = parse_statement(&text).expect("re-parse");
9562        assert_eq!(original, again);
9563    }
9564
9565    #[test]
9566    fn insert_round_trip_with_negation_and_string() {
9567        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
9568        let text = original.to_string();
9569        let again = parse_statement(&text).expect("re-parse");
9570        assert_eq!(original, again);
9571    }
9572
9573    #[test]
9574    fn unknown_keyword_at_statement_start_errors() {
9575        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
9576        // the top-level dispatch still has no branch to take.
9577        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
9578        assert!(err.message.contains("expected SELECT"));
9579    }
9580
9581    // --- v0.8 CREATE INDEX --------------------------------------------------
9582
9583    #[test]
9584    fn create_index_basic() {
9585        let s = parse("CREATE INDEX idx_id ON users (id)");
9586        let Statement::CreateIndex(c) = s else {
9587            panic!("expected CreateIndex")
9588        };
9589        assert_eq!(c.name, "idx_id");
9590        assert_eq!(c.table, "users");
9591        assert_eq!(c.column, "id");
9592    }
9593
9594    #[test]
9595    fn create_index_missing_on_errors() {
9596        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
9597    }
9598
9599    #[test]
9600    fn create_index_missing_paren_errors() {
9601        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
9602    }
9603
9604    #[test]
9605    fn create_index_round_trip() {
9606        let original = parse("CREATE INDEX by_name ON users (name)");
9607        let again = parse_statement(&original.to_string()).unwrap();
9608        assert_eq!(original, again);
9609    }
9610
9611    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
9612
9613    #[test]
9614    fn create_unique_index_basic() {
9615        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
9616        let Statement::CreateIndex(c) = s else {
9617            panic!("expected CreateIndex");
9618        };
9619        assert!(c.is_unique);
9620        assert_eq!(c.column, "a");
9621        assert!(c.partial_predicate.is_none());
9622    }
9623
9624    #[test]
9625    fn create_unique_index_partial() {
9626        // mailrs's email_templates "one default per user" shape.
9627        let s = parse(
9628            "CREATE UNIQUE INDEX idx_email_templates_user_default \
9629             ON email_templates (user_address) WHERE is_default = true",
9630        );
9631        let Statement::CreateIndex(c) = s else {
9632            panic!("expected CreateIndex");
9633        };
9634        assert!(c.is_unique);
9635        assert_eq!(c.table, "email_templates");
9636        assert_eq!(c.column, "user_address");
9637        assert!(c.partial_predicate.is_some());
9638    }
9639
9640    #[test]
9641    fn create_unique_index_composite_with_predicate() {
9642        // mailrs's calendar_events instance: composite columns.
9643        let s = parse(
9644            "CREATE UNIQUE INDEX uq_calendar_events_instance \
9645             ON calendar_events (calendar_id, uid, recurrence_id) \
9646             WHERE recurrence_id IS NOT NULL",
9647        );
9648        let Statement::CreateIndex(c) = s else {
9649            panic!("expected CreateIndex");
9650        };
9651        assert!(c.is_unique);
9652        assert_eq!(c.column, "calendar_id");
9653        assert_eq!(
9654            c.extra_columns,
9655            vec!["uid".to_string(), "recurrence_id".to_string()]
9656        );
9657        assert!(c.partial_predicate.is_some());
9658    }
9659
9660    #[test]
9661    fn create_unique_index_using_btree_ok() {
9662        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
9663        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
9664    }
9665
9666    #[test]
9667    fn create_unique_index_using_hnsw_rejected() {
9668        let err =
9669            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
9670        assert!(err.message.contains("UNIQUE"), "{}", err.message);
9671    }
9672
9673    #[test]
9674    fn create_unique_index_round_trip() {
9675        let original = parse(
9676            "CREATE UNIQUE INDEX uq_calendar_events_master \
9677             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
9678        );
9679        let again = parse_statement(&original.to_string()).unwrap();
9680        assert_eq!(original, again);
9681    }
9682
9683    #[test]
9684    fn create_unique_without_index_errors() {
9685        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
9686        assert!(err.message.contains("INDEX"), "{}", err.message);
9687    }
9688
9689    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
9690
9691    #[test]
9692    fn create_table_bytea_column() {
9693        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
9694        let Statement::CreateTable(c) = s else {
9695            panic!("expected CreateTable");
9696        };
9697        assert_eq!(c.columns.len(), 2);
9698        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
9699        assert!(!c.columns[1].nullable);
9700    }
9701
9702    #[test]
9703    fn create_table_bytes_alias_column() {
9704        let s = parse("CREATE TABLE t (blob BYTES)");
9705        let Statement::CreateTable(c) = s else {
9706            panic!("expected CreateTable");
9707        };
9708        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
9709    }
9710
9711    #[test]
9712    fn bytea_round_trip_display() {
9713        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
9714        let again = parse_statement(&original.to_string()).unwrap();
9715        assert_eq!(original, again);
9716    }
9717
9718    // --- v0.9 transactions -------------------------------------------------
9719
9720    #[test]
9721    fn begin_commit_rollback_parse_as_unit_variants() {
9722        assert_eq!(parse("BEGIN"), Statement::Begin);
9723        assert_eq!(parse("COMMIT"), Statement::Commit);
9724        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
9725        // Trailing semicolons accepted too.
9726        assert_eq!(parse("BEGIN;"), Statement::Begin);
9727    }
9728
9729    // --- v1.2: pgvector distance ops + ::vector cast --------------------
9730
9731    #[test]
9732    fn inner_product_binop_parses() {
9733        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
9734        let Statement::Select(s) = s else { panic!() };
9735        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9736            panic!()
9737        };
9738        assert!(matches!(
9739            expr,
9740            Expr::Binary {
9741                op: BinOp::InnerProduct,
9742                ..
9743            }
9744        ));
9745    }
9746
9747    #[test]
9748    fn cosine_distance_binop_parses() {
9749        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
9750        let Statement::Select(s) = s else { panic!() };
9751        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9752            panic!()
9753        };
9754        assert!(matches!(
9755            expr,
9756            Expr::Binary {
9757                op: BinOp::CosineDistance,
9758                ..
9759            }
9760        ));
9761    }
9762
9763    #[test]
9764    fn vector_cast_postfix_wraps_string_literal() {
9765        let s = parse("SELECT '[1,2,3]'::vector FROM t");
9766        let Statement::Select(s) = s else { panic!() };
9767        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9768            panic!()
9769        };
9770        assert!(matches!(
9771            expr,
9772            Expr::Cast {
9773                target: CastTarget::Vector,
9774                ..
9775            }
9776        ));
9777    }
9778
9779    #[test]
9780    fn unsupported_cast_target_errors() {
9781        // `::numeric` isn't in the v1.3 cast target set.
9782        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
9783        assert!(err.message.contains("unsupported cast target"));
9784    }
9785
9786    #[test]
9787    fn tx_statements_round_trip() {
9788        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
9789            let original = parse(q);
9790            let again = parse_statement(&original.to_string()).unwrap();
9791            assert_eq!(original, again);
9792        }
9793    }
9794
9795    #[test]
9796    fn interval_text_parsing_units() {
9797        // Single unit.
9798        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
9799        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
9800        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
9801        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
9802        // Compound spans accumulate.
9803        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
9804        assert_eq!(
9805            parse_interval_text("1 day 2 hours"),
9806            Some((0, 86_400_000_000 + 7_200_000_000))
9807        );
9808        // Negative numbers carry through.
9809        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
9810        // Bad shapes return None.
9811        assert_eq!(parse_interval_text(""), None);
9812        assert_eq!(parse_interval_text("garbage"), None);
9813        assert_eq!(parse_interval_text("1 fortnight"), None);
9814        assert_eq!(parse_interval_text("1"), None);
9815    }
9816
9817    #[test]
9818    fn interval_literal_roundtrips_via_display() {
9819        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
9820        let s = parsed.to_string();
9821        // Display preserves the original text verbatim.
9822        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
9823        // And re-parsing yields a structurally equal statement.
9824        let again = parse_statement(&s).unwrap();
9825        assert_eq!(parsed, again);
9826    }
9827
9828    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
9829
9830    #[test]
9831    fn parser_recognises_create_publication_bare() {
9832        let s = parse("CREATE PUBLICATION pub_a");
9833        let Statement::CreatePublication(p) = s else {
9834            panic!("expected CreatePublication, got {s:?}")
9835        };
9836        assert_eq!(p.name, "pub_a");
9837        assert_eq!(p.scope, PublicationScope::AllTables);
9838    }
9839
9840    #[test]
9841    fn parser_recognises_create_publication_for_all_tables() {
9842        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
9843        let Statement::CreatePublication(p) = s else {
9844            panic!("expected CreatePublication, got {s:?}")
9845        };
9846        assert_eq!(p.name, "pub_a");
9847        assert_eq!(p.scope, PublicationScope::AllTables);
9848    }
9849
9850    #[test]
9851    fn parser_recognises_drop_publication() {
9852        let s = parse("DROP PUBLICATION pub_a");
9853        let Statement::DropPublication(name) = s else {
9854            panic!("expected DropPublication, got {s:?}")
9855        };
9856        assert_eq!(name, "pub_a");
9857    }
9858
9859    #[test]
9860    fn parser_recognises_for_table_list() {
9861        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
9862        let Statement::CreatePublication(p) = s else {
9863            panic!("expected CreatePublication, got {s:?}")
9864        };
9865        assert_eq!(p.name, "pub_a");
9866        let PublicationScope::ForTables(ts) = p.scope else {
9867            panic!("expected ForTables scope")
9868        };
9869        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
9870    }
9871
9872    #[test]
9873    fn parser_recognises_for_tables_plural() {
9874        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
9875        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
9876        let Statement::CreatePublication(p) = s else {
9877            panic!("expected CreatePublication, got {s:?}")
9878        };
9879        let PublicationScope::ForTables(ts) = p.scope else {
9880            panic!("expected ForTables")
9881        };
9882        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9883    }
9884
9885    #[test]
9886    fn parser_recognises_for_all_tables_except_list() {
9887        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
9888        let Statement::CreatePublication(p) = s else {
9889            panic!()
9890        };
9891        let PublicationScope::AllTablesExcept(ts) = p.scope else {
9892            panic!("expected AllTablesExcept")
9893        };
9894        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9895    }
9896
9897    #[test]
9898    fn parser_rejects_for_table_with_empty_list() {
9899        // `FOR TABLE` with nothing after is a parse error.
9900        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
9901            .expect_err("must error on empty list");
9902        // No specific message asserted — the call falls through to
9903        // expect_ident_like which yields "expected identifier, got …".
9904        assert!(!err.message.is_empty());
9905    }
9906
9907    #[test]
9908    fn parser_recognises_show_publications() {
9909        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
9910        // bare ident in this position, NOT a reserved keyword.
9911        let s = parse("SHOW PUBLICATIONS");
9912        assert!(matches!(s, Statement::ShowPublications));
9913    }
9914
9915    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
9916
9917    #[test]
9918    fn parser_recognises_create_subscription_single_publication() {
9919        let s = parse(
9920            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
9921        );
9922        let Statement::CreateSubscription(c) = s else {
9923            panic!("expected CreateSubscription, got {s:?}")
9924        };
9925        assert_eq!(c.name, "sub_a");
9926        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
9927        assert_eq!(c.publications, alloc::vec!["pub_a"]);
9928    }
9929
9930    #[test]
9931    fn parser_recognises_create_subscription_multi_publication() {
9932        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
9933        let Statement::CreateSubscription(c) = s else {
9934            panic!()
9935        };
9936        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
9937    }
9938
9939    #[test]
9940    fn parser_rejects_create_subscription_missing_connection() {
9941        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
9942            .expect_err("must error on missing CONNECTION");
9943        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
9944    }
9945
9946    #[test]
9947    fn parser_rejects_create_subscription_missing_publication() {
9948        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
9949            .expect_err("must error on missing PUBLICATION");
9950        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
9951    }
9952
9953    #[test]
9954    fn parser_recognises_drop_subscription() {
9955        let s = parse("DROP SUBSCRIPTION sub_a");
9956        let Statement::DropSubscription(name) = s else {
9957            panic!("expected DropSubscription, got {s:?}")
9958        };
9959        assert_eq!(name, "sub_a");
9960    }
9961
9962    #[test]
9963    fn parser_recognises_show_subscriptions() {
9964        let s = parse("SHOW SUBSCRIPTIONS");
9965        assert!(matches!(s, Statement::ShowSubscriptions));
9966    }
9967
9968    #[test]
9969    fn parser_recognises_wait_for_wal_position_no_timeout() {
9970        let s = parse("WAIT FOR WAL POSITION 12345");
9971        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9972            panic!("expected WaitForWalPosition, got {s:?}")
9973        };
9974        assert_eq!(pos, 12345);
9975        assert!(timeout_ms.is_none());
9976    }
9977
9978    #[test]
9979    fn parser_recognises_wait_for_wal_position_with_timeout() {
9980        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
9981        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
9982            panic!()
9983        };
9984        assert_eq!(pos, 67890);
9985        assert_eq!(timeout_ms, Some(5000));
9986    }
9987
9988    #[test]
9989    fn parser_rejects_wait_with_negative_position() {
9990        // The lexer treats `-` as a token; `expect_u64_literal`
9991        // only sees the Integer that follows, so the negative
9992        // arrives as a unary-minus expression at higher levels.
9993        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
9994        // parse error one way or another.
9995        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
9996        assert!(!err.message.is_empty());
9997    }
9998
9999    #[test]
10000    fn parser_recognises_bare_analyze() {
10001        let s = parse("ANALYZE");
10002        assert!(matches!(s, Statement::Analyze(None)));
10003    }
10004
10005    #[test]
10006    fn parser_recognises_analyze_with_table() {
10007        let s = parse("ANALYZE users");
10008        let Statement::Analyze(Some(name)) = s else {
10009            panic!("expected Analyze, got {s:?}")
10010        };
10011        assert_eq!(name, "users");
10012    }
10013
10014    #[test]
10015    fn parser_recognises_analyze_with_quoted_table() {
10016        let s = parse("ANALYZE \"Mixed Case\"");
10017        let Statement::Analyze(Some(name)) = s else {
10018            panic!()
10019        };
10020        assert_eq!(name, "Mixed Case");
10021    }
10022
10023    #[test]
10024    fn parser_rejects_analyze_with_garbage_token() {
10025        let err = parse_statement("ANALYZE 42").expect_err("must error");
10026        assert!(!err.message.is_empty());
10027    }
10028
10029    #[test]
10030    fn analyze_display_roundtrips() {
10031        for sql in ["ANALYZE", "ANALYZE users"] {
10032            let s = parse(sql);
10033            let printed = s.to_string();
10034            let again = parse_statement(&printed)
10035                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10036            assert_eq!(s, again);
10037        }
10038    }
10039
10040    #[test]
10041    fn wait_for_display_roundtrips() {
10042        for sql in [
10043            "WAIT FOR WAL POSITION 12345",
10044            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
10045        ] {
10046            let s = parse(sql);
10047            let printed = s.to_string();
10048            let again = parse_statement(&printed)
10049                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10050            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10051        }
10052    }
10053
10054    #[test]
10055    fn subscription_ddl_display_roundtrips() {
10056        for sql in [
10057            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
10058            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
10059            "DROP SUBSCRIPTION sub_a",
10060            "SHOW SUBSCRIPTIONS",
10061        ] {
10062            let s = parse(sql);
10063            let printed = s.to_string();
10064            let again = parse_statement(&printed)
10065                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10066            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10067        }
10068    }
10069
10070    #[test]
10071    fn parser_drop_dispatches_user_vs_publication() {
10072        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
10073        // tokenises DROP. Both targets must still parse.
10074        let s = parse("DROP USER 'alice'");
10075        let Statement::DropUser(name) = s else {
10076            panic!("expected DropUser, got {s:?}")
10077        };
10078        assert_eq!(name, "alice");
10079        // And DROP PUBLICATION lands the new variant.
10080        let s = parse("DROP PUBLICATION p1");
10081        assert!(matches!(s, Statement::DropPublication(_)));
10082    }
10083
10084    #[test]
10085    fn publication_ddl_display_roundtrips() {
10086        // Every CREATE PUBLICATION variant must Display → parse →
10087        // same AST. v6.1.3 covers all three scope shapes.
10088        for sql in [
10089            "CREATE PUBLICATION pub_a",
10090            "CREATE PUBLICATION pub_a FOR ALL TABLES",
10091            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
10092            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
10093            "DROP PUBLICATION pub_a",
10094            "SHOW PUBLICATIONS",
10095        ] {
10096            let s = parse(sql);
10097            let printed = s.to_string();
10098            let again = parse_statement(&printed)
10099                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10100            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10101        }
10102    }
10103
10104    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
10105
10106    #[test]
10107    fn create_function_returns_trigger_plpgsql_minimal() {
10108        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
10109        let s = parse(sql);
10110        let Statement::CreateFunction(f) = s else {
10111            panic!("expected CreateFunction");
10112        };
10113        assert_eq!(f.name, "noop");
10114        assert!(!f.or_replace);
10115        assert!(f.args.is_empty());
10116        assert!(matches!(f.returns, FunctionReturn::Trigger));
10117        assert_eq!(f.language, "plpgsql");
10118        let FunctionBody::PlPgSql(block) = f.body else {
10119            panic!("expected PlPgSql body");
10120        };
10121        assert_eq!(block.statements.len(), 1);
10122        assert!(matches!(
10123            block.statements[0],
10124            PlPgSqlStmt::Return(ReturnTarget::New)
10125        ));
10126    }
10127
10128    #[test]
10129    fn create_function_or_replace_with_assignment() {
10130        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
10131        // RETURN NEW.
10132        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
10133BEGIN
10134  NEW.search_vector := to_tsvector('english', NEW.subject);
10135  RETURN NEW;
10136END;
10137$$";
10138        let s = parse(sql);
10139        let Statement::CreateFunction(f) = s else {
10140            panic!("expected CreateFunction");
10141        };
10142        assert!(f.or_replace);
10143        let FunctionBody::PlPgSql(block) = &f.body else {
10144            panic!("expected PlPgSql body");
10145        };
10146        assert_eq!(block.statements.len(), 2);
10147        // First statement: NEW.search_vector := to_tsvector(...)
10148        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
10149            panic!("expected Assign as first stmt");
10150        };
10151        match target {
10152            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
10153            other => panic!("expected NEW.col, got {other:?}"),
10154        }
10155        // Second statement: RETURN NEW
10156        assert!(matches!(
10157            block.statements[1],
10158            PlPgSqlStmt::Return(ReturnTarget::New)
10159        ));
10160    }
10161
10162    #[test]
10163    fn create_trigger_after_insert_or_update() {
10164        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
10165        let s = parse(sql);
10166        let Statement::CreateTrigger(t) = s else {
10167            panic!("expected CreateTrigger");
10168        };
10169        assert_eq!(t.name, "tg");
10170        assert_eq!(t.table, "messages");
10171        assert_eq!(t.timing, TriggerTiming::After);
10172        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
10173        assert_eq!(t.for_each, TriggerForEach::Row);
10174        assert_eq!(t.function, "update_sv");
10175    }
10176
10177    #[test]
10178    fn create_trigger_before_delete_execute_procedure_alias() {
10179        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
10180        let sql =
10181            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
10182        let s = parse(sql);
10183        let Statement::CreateTrigger(t) = s else {
10184            panic!("expected CreateTrigger");
10185        };
10186        assert_eq!(t.timing, TriggerTiming::Before);
10187        assert_eq!(t.events, vec![TriggerEvent::Delete]);
10188    }
10189
10190    #[test]
10191    fn drop_trigger_if_exists_round_trips() {
10192        // No parser support for DROP TRIGGER yet — added in v7.12.5
10193        // alongside the broader DROP …{IF EXISTS} cleanup. The
10194        // AST + Display impls are in place so we round-trip via
10195        // construction:
10196        let s = Statement::DropTrigger {
10197            name: "tg".into(),
10198            table: "messages".into(),
10199            if_exists: true,
10200        };
10201        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
10202    }
10203
10204    #[test]
10205    fn trigger_ddl_display_roundtrips_through_parser() {
10206        // CREATE TRIGGER + its referenced CREATE FUNCTION must
10207        // Display → parse → same AST (modulo PL/pgSQL body
10208        // formatting which is parser-canonicalised).
10209        for sql in [
10210            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
10211            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
10212        ] {
10213            let s = parse(sql);
10214            let printed = s.to_string();
10215            let again = parse_statement(&printed)
10216                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10217            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10218        }
10219    }
10220}