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_trigger",
4332            "pg_type",
4333            "pg_user",
4334            "pg_views",
4335        ];
4336        let name = match self.tokens.get(self.pos) {
4337            Some(Token::Ident(s)) => s.to_ascii_lowercase(),
4338            _ => return None,
4339        };
4340        // A following dot means this ident is a schema qualifier,
4341        // not a table name — let the qualified path handle it.
4342        if matches!(self.tokens.get(self.pos + 1), Some(Token::Dot)) {
4343            return None;
4344        }
4345        if !PG_META_TABLES.contains(&name.as_str()) {
4346            return None;
4347        }
4348        self.advance();
4349        let bare = name.strip_prefix("pg_").unwrap_or(&name);
4350        Some(alloc::format!("__spg_pg_{bare}"))
4351    }
4352
4353    /// Consume a bare ident if its lowercase matches `kw`, else err.
4354    fn expect_keyword_ident(&mut self, kw: &str) -> Result<(), ParseError> {
4355        match self.advance() {
4356            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case(kw) => Ok(()),
4357            other => Err(ParseError {
4358                message: format!("expected {kw:?}, got {other:?}"),
4359                token_pos: self.pos.saturating_sub(1),
4360            }),
4361        }
4362    }
4363
4364    /// Accept either a quoted identifier (`"foo"`) or a quoted string
4365    /// literal (`'foo'`) — same shape used by CREATE USER for the
4366    /// username slot.
4367    fn expect_ident_or_string(&mut self) -> Result<String, ParseError> {
4368        match self.advance() {
4369            Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => Ok(s),
4370            other => Err(ParseError {
4371                message: format!("expected identifier or string, got {other:?}"),
4372                token_pos: self.pos.saturating_sub(1),
4373            }),
4374        }
4375    }
4376
4377    fn expect_string_literal(&mut self) -> Result<String, ParseError> {
4378        match self.advance() {
4379            Token::String(s) => Ok(s),
4380            other => Err(ParseError {
4381                message: format!("expected quoted string, got {other:?}"),
4382                token_pos: self.pos.saturating_sub(1),
4383            }),
4384        }
4385    }
4386
4387    fn parse_select_stmt(&mut self) -> Result<Statement, ParseError> {
4388        // Caller dispatches on Token::Select; the inner helper handles
4389        // the rest. ORDER BY / LIMIT bind at this top level; UNION peers
4390        // get a fresh bare-select parse and may not have their own ORDER
4391        // BY / LIMIT.
4392        let mut head = self.parse_bare_select()?;
4393        while matches!(self.peek(), Token::Union) {
4394            self.advance();
4395            let kind = if matches!(self.peek(), Token::All) {
4396                self.advance();
4397                UnionKind::All
4398            } else {
4399                UnionKind::Distinct
4400            };
4401            let peer = self.parse_bare_select()?;
4402            head.unions.push((kind, peer));
4403        }
4404        head.order_by = if matches!(self.peek(), Token::Order) {
4405            self.advance();
4406            if !matches!(self.peek(), Token::By) {
4407                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
4408            }
4409            self.advance();
4410            // v6.4.0 — multi-key ORDER BY. Loop over comma-separated
4411            // `<expr> [ASC|DESC]` items.
4412            let mut keys = Vec::new();
4413            loop {
4414                let expr = self.parse_expr(0)?;
4415                let desc = if matches!(self.peek(), Token::Desc) {
4416                    self.advance();
4417                    true
4418                } else if matches!(self.peek(), Token::Asc) {
4419                    self.advance();
4420                    false
4421                } else {
4422                    false
4423                };
4424                // v7.24 (round-16 A) — explicit NULLS FIRST/LAST.
4425                let nulls_first = self.parse_optional_nulls_placement()?;
4426                keys.push(OrderBy {
4427                    expr,
4428                    desc,
4429                    nulls_first,
4430                });
4431                if matches!(self.peek(), Token::Comma) {
4432                    self.advance();
4433                } else {
4434                    break;
4435                }
4436            }
4437            keys
4438        } else {
4439            Vec::new()
4440        };
4441        head.limit = if matches!(self.peek(), Token::Limit) {
4442            self.advance();
4443            // v7.17.0 Phase 5.1 — `LIMIT NULL` / `LIMIT ALL` are
4444            // PG synonyms for "no limit". Treat both as None
4445            // (no head.limit set) so the engine's existing
4446            // unlimited-result path takes over. Reject was the
4447            // pre-5.1 behaviour and broke pg_dump-flavoured
4448            // tooling that occasionally emits LIMIT NULL.
4449            if self.consume_limit_unbounded_sentinel() {
4450                None
4451            } else {
4452                Some(self.parse_limit_expr("LIMIT")?)
4453            }
4454        } else {
4455            None
4456        };
4457        head.offset = if matches!(self.peek(), Token::Offset) {
4458            self.advance();
4459            // PG also accepts an optional `ROW` / `ROWS` trailer
4460            // after the offset value (`OFFSET 10 ROWS`). The
4461            // FETCH-FIRST branch below relies on the same.
4462            let off = self.parse_limit_expr("OFFSET")?;
4463            self.consume_optional_rows_keyword();
4464            Some(off)
4465        } else {
4466            None
4467        };
4468        // v7.17.0 Phase 5.1 — `FETCH FIRST <int|$N> ROWS ONLY` is
4469        // the SQL-standard alias for LIMIT. PG accepts both
4470        // spellings interchangeably; pg_dump emits FETCH FIRST in
4471        // newer versions. We map it onto `head.limit` so the
4472        // engine path is unified.
4473        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("fetch"))
4474        {
4475            self.advance(); // FETCH
4476            // `FIRST` or `NEXT` (both legal per SQL standard).
4477            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4478                if s.eq_ignore_ascii_case("first") || s.eq_ignore_ascii_case("next"))
4479            {
4480                self.advance();
4481            }
4482            // Count (optional in the bare `FETCH FIRST ROW ONLY` —
4483            // implicit 1 — but we always consume one if present).
4484            let count = if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4485                if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4486            {
4487                // Bare `FETCH FIRST ROW ONLY` = LIMIT 1.
4488                crate::ast::LimitExpr::Literal(1)
4489            } else {
4490                self.parse_limit_expr("FETCH FIRST")?
4491            };
4492            // Eat `ROW` / `ROWS` if not already consumed above.
4493            self.consume_optional_rows_keyword();
4494            // Optional `ONLY` (the spec form) — or the SQL:2008
4495            // `WITH TIES` form. v7.17.0 Phase 3.P0-49: the executor
4496            // now honours WITH TIES by extending past the LIMIT
4497            // truncation point through every row that shares the
4498            // last-kept row's ORDER BY key.
4499            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4500                if s.eq_ignore_ascii_case("only"))
4501            {
4502                self.advance();
4503            } else if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4504                if s.eq_ignore_ascii_case("with"))
4505            {
4506                self.advance(); // WITH
4507                if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4508                    if s.eq_ignore_ascii_case("ties"))
4509                {
4510                    self.advance();
4511                    head.limit_with_ties = true;
4512                }
4513            }
4514            head.limit = Some(count);
4515        }
4516        // v7.17.0 Phase 3.4 — trailing row-lock clauses:
4517        //   FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE }
4518        //       [ OF table_name [, …] ]
4519        //       [ NOWAIT | SKIP LOCKED ]
4520        // Multiple FOR clauses may stack (PG: `FOR UPDATE OF t1
4521        // FOR SHARE OF t2`). SPG is a single-writer engine — every
4522        // SELECT already returns a consistent snapshot — so these
4523        // are accept-and-discard: the parser absorbs them so
4524        // mailrs / Rails / Django code paths that emit `SELECT
4525        // … FOR UPDATE` for advisory pessimistic locking load
4526        // without a parser error. The on-disk locking model is
4527        // unchanged; callers that rely on FOR UPDATE for read-
4528        // through-write ordering still get the right answer
4529        // because SPG serialises writes anyway.
4530        self.consume_optional_for_lock_clauses();
4531        Ok(Statement::Select(head))
4532    }
4533
4534    /// v7.17.0 Phase 3.4 — eat zero or more `FOR { UPDATE | NO KEY
4535    /// UPDATE | SHARE | KEY SHARE } [ OF tbl[, …] ] [ NOWAIT | SKIP
4536    /// LOCKED ]` trailers. Each clause is fully accepted and
4537    /// discarded — SPG's single-writer model already satisfies the
4538    /// callers' implicit ordering requirement. Stops at the first
4539    /// token that isn't `FOR`.
4540    fn consume_optional_for_lock_clauses(&mut self) {
4541        while matches!(self.peek(), Token::For) {
4542            self.advance(); // FOR
4543            // `NO KEY` prefix (PG) — `NO` is reserved-keyword-shaped
4544            // (`Token::Not` isn't it; PG `NO` lexes as Token::Ident).
4545            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4546                if s.eq_ignore_ascii_case("no"))
4547            {
4548                self.advance(); // NO
4549                // The next ident should be KEY but be generous;
4550                // anything followed by UPDATE/SHARE is accepted.
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            }
4557            // `KEY` prefix (PG `FOR KEY SHARE`).
4558            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4559                if s.eq_ignore_ascii_case("key"))
4560            {
4561                self.advance(); // KEY
4562            }
4563            // Lock-strength keyword: UPDATE / SHARE. Required, but
4564            // we're lenient — an unexpected token here just bails
4565            // (we already consumed FOR; caller's downstream
4566            // dispatch will error if anything actually depends on
4567            // the trailing tokens).
4568            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4569                if s.eq_ignore_ascii_case("update") || s.eq_ignore_ascii_case("share"))
4570            {
4571                self.advance();
4572            } else {
4573                // FOR by itself (or `FOR KEY` with nothing after) —
4574                // give up on the lock-clause path. We've already
4575                // advanced past FOR; further attempts to parse
4576                // here would clobber state.
4577                return;
4578            }
4579            // Optional `OF tbl[, tbl …]`. mailrs emits this when
4580            // joining and locking only a subset of tables.
4581            if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4582                if s.eq_ignore_ascii_case("of"))
4583            {
4584                self.advance(); // OF
4585                #[allow(clippy::while_let_loop)]
4586                loop {
4587                    match self.peek() {
4588                        Token::Ident(_) | Token::QuotedIdent(_) => {
4589                            self.advance();
4590                            // Optional schema-qualified `schema.table`.
4591                            if matches!(self.peek(), Token::Dot) {
4592                                self.advance();
4593                                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4594                                    self.advance();
4595                                }
4596                            }
4597                        }
4598                        _ => break,
4599                    }
4600                    if matches!(self.peek(), Token::Comma) {
4601                        self.advance();
4602                    } else {
4603                        break;
4604                    }
4605                }
4606            }
4607            // Optional `NOWAIT` | `SKIP LOCKED`.
4608            match self.peek().clone() {
4609                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nowait") => {
4610                    self.advance();
4611                }
4612                Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("skip") => {
4613                    self.advance(); // SKIP
4614                    if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4615                        if s.eq_ignore_ascii_case("locked"))
4616                    {
4617                        self.advance(); // LOCKED
4618                    }
4619                }
4620                _ => {}
4621            }
4622            // Loop: PG allows multiple FOR clauses chained.
4623        }
4624    }
4625
4626    /// v7.9.24 — accept `LIMIT <int>` or `LIMIT $N`. mailrs H2.
4627    /// Bind value gets resolved during prepared-statement Execute;
4628    /// the Pratt expression parser would over-accept here (e.g.
4629    /// `LIMIT 5 + 5`), so we narrowly accept only the two PG forms.
4630    /// v7.17.0 Phase 5.1 — consume the `LIMIT NULL` / `LIMIT ALL`
4631    /// sentinel tokens (PG synonyms for "no limit"). Returns true
4632    /// when one was consumed; caller skips the regular
4633    /// limit-value parse and leaves `head.limit` at None.
4634    fn consume_limit_unbounded_sentinel(&mut self) -> bool {
4635        if matches!(self.peek(), Token::Null) {
4636            self.advance();
4637            return true;
4638        }
4639        if matches!(self.peek(), Token::All) {
4640            self.advance();
4641            return true;
4642        }
4643        false
4644    }
4645
4646    /// v7.17.0 Phase 5.1 — eat an optional trailing `ROW` / `ROWS`
4647    /// keyword after a LIMIT / OFFSET / FETCH FIRST value, the
4648    /// SQL-standard shape. No-op when missing.
4649    fn consume_optional_rows_keyword(&mut self) {
4650        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s)
4651            if s.eq_ignore_ascii_case("row") || s.eq_ignore_ascii_case("rows"))
4652        {
4653            self.advance();
4654        }
4655    }
4656
4657    fn parse_limit_expr(&mut self, label: &str) -> Result<crate::ast::LimitExpr, ParseError> {
4658        match self.advance() {
4659            Token::Integer(n) if n >= 0 => u32::try_from(n)
4660                .map(crate::ast::LimitExpr::Literal)
4661                .map_err(|_| ParseError {
4662                    message: alloc::format!("{label} value too large: {n}"),
4663                    token_pos: self.pos.saturating_sub(1),
4664                }),
4665            Token::Placeholder(n) => Ok(crate::ast::LimitExpr::Placeholder(n)),
4666            other => Err(ParseError {
4667                message: alloc::format!(
4668                    "expected non-negative integer or $N placeholder after {label}, got {other:?}"
4669                ),
4670                token_pos: self.pos.saturating_sub(1),
4671            }),
4672        }
4673    }
4674
4675    /// Parse one SELECT block without ORDER BY / LIMIT / UNION chaining —
4676    /// just `[DISTINCT] items [FROM] [WHERE] [GROUP BY]`. Returned with
4677    /// `unions` empty and `order_by` / `limit` `None`; the top-level
4678    /// `parse_select_stmt` is responsible for filling those in.
4679    fn parse_bare_select(&mut self) -> Result<SelectStatement, ParseError> {
4680        if !matches!(self.peek(), Token::Select) {
4681            return Err(self.err(format!(
4682                "expected SELECT to start a query block, got {:?}",
4683                self.peek()
4684            )));
4685        }
4686        self.advance();
4687        let distinct = if matches!(self.peek(), Token::Distinct) {
4688            self.advance();
4689            true
4690        } else {
4691            false
4692        };
4693        let items = self.parse_select_list()?;
4694        let from = if matches!(self.peek(), Token::From) {
4695            self.advance();
4696            Some(self.parse_from_clause()?)
4697        } else {
4698            None
4699        };
4700        let where_ = if matches!(self.peek(), Token::Where) {
4701            self.advance();
4702            Some(self.parse_expr(0)?)
4703        } else {
4704            None
4705        };
4706        let mut group_by_all = false;
4707        let group_by = if matches!(self.peek(), Token::Group) {
4708            self.advance();
4709            if !matches!(self.peek(), Token::By) {
4710                return Err(self.err(format!("expected BY after GROUP, got {:?}", self.peek())));
4711            }
4712            self.advance();
4713            // v6.4.1 — `GROUP BY ALL` shortcut. Planner expands to
4714            // every non-aggregate SELECT-list item later.
4715            if matches!(self.peek(), Token::All) {
4716                self.advance();
4717                group_by_all = true;
4718                None
4719            } else {
4720                let mut groups = Vec::new();
4721                loop {
4722                    groups.push(self.parse_expr(0)?);
4723                    if matches!(self.peek(), Token::Comma) {
4724                        self.advance();
4725                    } else {
4726                        break;
4727                    }
4728                }
4729                Some(groups)
4730            }
4731        } else {
4732            None
4733        };
4734        let having = if matches!(self.peek(), Token::Having) {
4735            self.advance();
4736            Some(self.parse_expr(0)?)
4737        } else {
4738            None
4739        };
4740        Ok(SelectStatement {
4741            ctes: Vec::new(),
4742            distinct,
4743            items,
4744            from,
4745            where_,
4746            group_by,
4747            group_by_all,
4748            having,
4749            unions: Vec::new(),
4750            order_by: Vec::new(),
4751            limit: None,
4752            offset: None,
4753            limit_with_ties: false,
4754        })
4755    }
4756
4757    fn parse_create_table_stmt_after_create(&mut self) -> Result<Statement, ParseError> {
4758        // Caller already consumed CREATE; we're sitting on TABLE.
4759        debug_assert!(matches!(self.peek(), Token::Table));
4760        self.advance();
4761        let if_not_exists = self.consume_if_not_exists();
4762        let name = self.expect_ident_like()?;
4763        if !matches!(self.peek(), Token::LParen) {
4764            return Err(self.err(format!(
4765                "expected '(' after table name, got {:?}",
4766                self.peek()
4767            )));
4768        }
4769        self.advance();
4770        let mut columns = Vec::new();
4771        let mut foreign_keys: Vec<ForeignKeyConstraint> = Vec::new();
4772        let mut table_constraints: Vec<crate::ast::TableConstraint> = Vec::new();
4773        loop {
4774            // v7.6.0 / v7.9.18 — distinguish table-level constraint
4775            // clauses from column definitions. Constraints start
4776            // with `CONSTRAINT <name> …`, `FOREIGN KEY (…)`,
4777            // `PRIMARY KEY (…)`, or `UNIQUE (…)`. Anything else is
4778            // a column.
4779            if self.peek_table_level_pk_start() {
4780                table_constraints.push(self.parse_table_level_primary_key()?);
4781            } else if self.peek_table_level_unique_start() {
4782                table_constraints.push(self.parse_table_level_unique()?);
4783            } else if self.peek_table_level_check_start() {
4784                // v7.13.0 — table-level CHECK (mailrs round-5 G3).
4785                table_constraints.push(self.parse_table_level_check()?);
4786            } else if self.peek_mysql_inline_key_start() {
4787                // v7.14.0 — mysqldump emits inline `KEY name (cols)`,
4788                // `INDEX name (cols)`, `UNIQUE KEY name (cols)`,
4789                // `FULLTEXT KEY name (cols)`, `SPATIAL KEY name (cols)`
4790                // inside the column list. Skip name + paren list;
4791                // for UNIQUE KEY, register as a UC.
4792                if let Some(uc) = self.parse_mysql_inline_key()? {
4793                    table_constraints.push(uc);
4794                }
4795            } else if let Some(kind) = self.peek_named_table_constraint_kind() {
4796                // v7.22 (mailrs round-13 gap 5) — `CONSTRAINT <name>
4797                // { CHECK | UNIQUE | PRIMARY KEY }`: every pg_dump'd
4798                // CHECK is named, and the named-CONSTRAINT arm used
4799                // to accept FOREIGN KEY only. The name is accepted
4800                // and discarded — same handling as every other SPG
4801                // constraint name.
4802                self.advance(); // CONSTRAINT
4803                let _name = self.expect_ident_like()?;
4804                table_constraints.push(match kind {
4805                    NamedTableConstraintKind::Check => self.parse_table_level_check()?,
4806                    NamedTableConstraintKind::Unique => self.parse_table_level_unique()?,
4807                    NamedTableConstraintKind::PrimaryKey => self.parse_table_level_primary_key()?,
4808                });
4809            } else if self.peek_constraint_or_fk_start() {
4810                foreign_keys.push(self.parse_table_level_fk()?);
4811            } else {
4812                let (col, col_level_fk) = self.parse_column_def_with_fk()?;
4813                // v7.13.0 — fold inline UNIQUE / CHECK column
4814                // constraints into table-level entries so the
4815                // engine path stays uniform.
4816                if col.is_unique {
4817                    table_constraints.push(crate::ast::TableConstraint::Unique {
4818                        name: None,
4819                        columns: alloc::vec![col.name.clone()],
4820                        nulls_not_distinct: false,
4821                    });
4822                }
4823                if let Some(check_expr) = col.check.clone() {
4824                    table_constraints.push(crate::ast::TableConstraint::Check {
4825                        name: None,
4826                        expr: check_expr,
4827                    });
4828                }
4829                columns.push(col);
4830                if let Some(fk) = col_level_fk {
4831                    foreign_keys.push(fk);
4832                }
4833            }
4834            match self.peek() {
4835                Token::Comma => {
4836                    self.advance();
4837                }
4838                Token::RParen => {
4839                    self.advance();
4840                    break;
4841                }
4842                other => {
4843                    return Err(
4844                        self.err(format!("expected ',' or ')' in column list, got {other:?}"))
4845                    );
4846                }
4847            }
4848        }
4849        if columns.is_empty() {
4850            return Err(self.err("CREATE TABLE requires at least one column".into()));
4851        }
4852        // v7.14.0 — consume MySQL/MariaDB table options after the
4853        // closing `)`. mysqldump emits things like
4854        // `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
4855        // AUTO_INCREMENT=42 ROW_FORMAT=DYNAMIC COMMENT='blog posts'`.
4856        // SPG accepts all forms as no-ops (each option is
4857        // `<ident> [=] <ident-or-string>` separated by whitespace).
4858        self.consume_mysql_table_options();
4859        Ok(Statement::CreateTable(CreateTableStatement {
4860            name,
4861            columns,
4862            if_not_exists,
4863            foreign_keys,
4864            table_constraints,
4865        }))
4866    }
4867
4868    /// v7.14.0 — true when the next tokens look like an inline
4869    /// MySQL index declaration: KEY / INDEX / UNIQUE KEY /
4870    /// UNIQUE INDEX / FULLTEXT [KEY|INDEX] / SPATIAL [KEY|INDEX]
4871    /// — each followed by an optional name + `(...)`. Critical:
4872    /// a column NAMED `key` / `index` (PG accepts as ident) must
4873    /// NOT be mistaken for the KEY constraint shape. We disambig
4874    /// by requiring the keyword to be followed by either `(` or
4875    /// `<ident> (`.
4876    fn peek_mysql_inline_key_start(&self) -> bool {
4877        let cur = self.peek();
4878        // Shapes:
4879        //   KEY (cols)
4880        //   KEY name (cols)
4881        //   INDEX (cols)
4882        //   INDEX name (cols)
4883        //   UNIQUE KEY [name] (cols)
4884        //   UNIQUE INDEX [name] (cols)
4885        //   FULLTEXT [KEY|INDEX] [name] (cols)
4886        //   SPATIAL [KEY|INDEX] [name] (cols)
4887        let after_keyword_followed_by_paren_or_ident_paren = |skip: usize| -> bool {
4888            // tokens at skip = the position AFTER the index-form
4889            // keywords (KEY/INDEX) have been consumed.
4890            match self.tokens.get(skip) {
4891                Some(Token::LParen) => true,
4892                Some(Token::Ident(_) | Token::QuotedIdent(_)) => {
4893                    matches!(self.tokens.get(skip + 1), Some(Token::LParen))
4894                }
4895                _ => false,
4896            }
4897        };
4898        // `INDEX` lexes as Token::Index (reserved), not as
4899        // Token::Ident("index"). Both shapes count as a KEY/INDEX
4900        // start; the peek helper below handles either.
4901        let is_key_or_index_tok = |t: &Token| -> bool {
4902            matches!(t, Token::Index)
4903                || matches!(t, Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index"))
4904        };
4905        match cur {
4906            Token::Index => after_keyword_followed_by_paren_or_ident_paren(self.pos + 1),
4907            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4908                after_keyword_followed_by_paren_or_ident_paren(self.pos + 1)
4909            }
4910            Token::Ident(s)
4911                if s.eq_ignore_ascii_case("fulltext") || s.eq_ignore_ascii_case("spatial") =>
4912            {
4913                let nxt = self.tokens.get(self.pos + 1);
4914                let after_after = if nxt.is_some_and(is_key_or_index_tok) {
4915                    self.pos + 2
4916                } else {
4917                    self.pos + 1
4918                };
4919                after_keyword_followed_by_paren_or_ident_paren(after_after)
4920            }
4921            Token::Ident(s) if s.eq_ignore_ascii_case("unique") => {
4922                let nxt = self.tokens.get(self.pos + 1);
4923                if !nxt.is_some_and(is_key_or_index_tok) {
4924                    return false;
4925                }
4926                after_keyword_followed_by_paren_or_ident_paren(self.pos + 2)
4927            }
4928            _ => false,
4929        }
4930    }
4931
4932    /// v7.14.0 — parse the MySQL inline KEY/INDEX form. Returns
4933    /// Some(TableConstraint::Unique) for UNIQUE KEY (so SPG
4934    /// enforces uniqueness on INSERT). v7.15.0: plain KEY/INDEX
4935    /// returns Some(TableConstraint::Index) so the engine builds
4936    /// a real BTree index on the leading column (mysqldump
4937    /// `KEY idx_posts_author (author_id)` shape).
4938    /// FULLTEXT / SPATIAL still return None — accepted-as-no-op
4939    /// (the storage layer has no matching AM).
4940    fn parse_mysql_inline_key(
4941        &mut self,
4942    ) -> Result<Option<crate::ast::TableConstraint>, ParseError> {
4943        // Detect UNIQUE prefix.
4944        let is_unique = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unique"))
4945        {
4946            self.advance();
4947            true
4948        } else {
4949            false
4950        };
4951        // Consume FULLTEXT / SPATIAL prefix and record which one
4952        // it was. v7.17.0 Phase 2.2 — FULLTEXT routes through a
4953        // dedicated TableConstraint variant so the engine can
4954        // build a tsvector-GIN; SPATIAL still has no matching
4955        // AM, so it falls back to accept-as-no-op.
4956        let mut is_fulltext = false;
4957        let mut is_spatial = false;
4958        if let Token::Ident(s) = self.peek().clone() {
4959            if s.eq_ignore_ascii_case("fulltext") {
4960                self.advance();
4961                is_fulltext = true;
4962            } else if s.eq_ignore_ascii_case("spatial") {
4963                self.advance();
4964                is_spatial = true;
4965            }
4966        }
4967        // KEY / INDEX keyword. `INDEX` lexes as Token::Index
4968        // (reserved); accept either token shape.
4969        match self.peek() {
4970            Token::Index => {
4971                self.advance();
4972            }
4973            Token::Ident(s) if s.eq_ignore_ascii_case("key") || s.eq_ignore_ascii_case("index") => {
4974                self.advance();
4975            }
4976            other => {
4977                return Err(self.err(alloc::format!(
4978                    "expected KEY/INDEX in inline index declaration, got {other:?}"
4979                )));
4980            }
4981        }
4982        // Optional index name (an ident before the `(`).
4983        // v7.15.0 — capture the name when present so the engine
4984        // builds the secondary index under the user's chosen
4985        // name (matches mysqldump's `KEY idx_x (col)` shape).
4986        let mut idx_name: Option<String> = None;
4987        if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_))
4988            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
4989        {
4990            if let Token::Ident(s) | Token::QuotedIdent(s) = self.advance() {
4991                idx_name = Some(s);
4992            }
4993        }
4994        // Optional `USING BTREE` / `USING HASH` (MySQL).
4995        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
4996            self.advance();
4997            if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
4998                self.advance();
4999            }
5000        }
5001        // Required column list `(col [, col]*)`.
5002        if !matches!(self.peek(), Token::LParen) {
5003            return Err(self.err(alloc::format!(
5004                "expected '(' in inline KEY/INDEX, got {:?}",
5005                self.peek()
5006            )));
5007        }
5008        self.advance();
5009        let mut cols: Vec<String> = Vec::new();
5010        while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
5011            self.advance();
5012            cols.push(s);
5013            // Skip optional `(length)` per-column prefix.
5014            if matches!(self.peek(), Token::LParen) {
5015                let mut depth = 1usize;
5016                self.advance();
5017                while depth > 0 {
5018                    match self.peek() {
5019                        Token::LParen => depth += 1,
5020                        Token::RParen => depth -= 1,
5021                        Token::Eof => break,
5022                        _ => {}
5023                    }
5024                    self.advance();
5025                }
5026            }
5027            // Skip optional ASC / DESC.
5028            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("asc") || s.eq_ignore_ascii_case("desc"))
5029                || matches!(self.peek(), Token::Asc | Token::Desc)
5030            {
5031                self.advance();
5032            }
5033            if matches!(self.peek(), Token::Comma) {
5034                self.advance();
5035                continue;
5036            }
5037            break;
5038        }
5039        if matches!(self.peek(), Token::RParen) {
5040            self.advance();
5041        }
5042        // Trailing options on the inline index — comment / etc.
5043        // Skip until comma or `)`.
5044        while !matches!(self.peek(), Token::Comma | Token::RParen | Token::Eof) {
5045            self.advance();
5046        }
5047        if cols.is_empty() {
5048            return Ok(None);
5049        }
5050        if is_unique {
5051            // Carry the captured idx_name on UNIQUE too so future
5052            // engine work can name the underlying BTree
5053            // accordingly; today the unique-constraint installer
5054            // synthesises the name itself, but Display round-trip
5055            // benefits from preserving it.
5056            Ok(Some(crate::ast::TableConstraint::Unique {
5057                name: idx_name,
5058                columns: cols,
5059                nulls_not_distinct: false,
5060            }))
5061        } else if is_fulltext {
5062            // v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` now
5063            // routes through `TableConstraint::FulltextIndex`;
5064            // the engine builds a tsvector-GIN over each named
5065            // column so MATCH AGAINST gets a real inverted
5066            // index instead of a silently-dropped declaration.
5067            Ok(Some(crate::ast::TableConstraint::FulltextIndex {
5068                name: idx_name,
5069                columns: cols,
5070            }))
5071        } else if is_spatial {
5072            // SPG has no native SPATIAL AM. Accept-as-no-op
5073            // (declaration is parsed, but no index is built).
5074            Ok(None)
5075        } else {
5076            // v7.15.0 — plain KEY / INDEX builds a real BTree
5077            // secondary index.
5078            Ok(Some(crate::ast::TableConstraint::Index {
5079                name: idx_name,
5080                columns: cols,
5081            }))
5082        }
5083    }
5084
5085    /// v7.14.0 — consume MySQL/MariaDB table-options tail after
5086    /// the closing `)`: ENGINE=..., DEFAULT CHARSET=...,
5087    /// COLLATE=..., AUTO_INCREMENT=N, ROW_FORMAT=..., COMMENT='...'
5088    /// (in any order, separated by whitespace).
5089    fn consume_mysql_table_options(&mut self) {
5090        loop {
5091            // Heuristic: a table option is an ident (or `DEFAULT`
5092            // reserved keyword) followed by `=` and an
5093            // ident / string / integer.
5094            let name_lc = match self.peek().clone() {
5095                Token::Ident(s) | Token::QuotedIdent(s) => s.to_ascii_lowercase(),
5096                Token::Default => alloc::string::String::from("default"),
5097                _ => break,
5098            };
5099            let known = matches!(
5100                name_lc.as_str(),
5101                "engine"
5102                    | "default"
5103                    | "charset"
5104                    | "collate"
5105                    | "auto_increment"
5106                    | "row_format"
5107                    | "comment"
5108                    | "pack_keys"
5109                    | "stats_persistent"
5110                    | "stats_auto_recalc"
5111                    | "stats_sample_pages"
5112                    | "key_block_size"
5113                    | "tablespace"
5114                    | "min_rows"
5115                    | "max_rows"
5116                    | "checksum"
5117                    | "delay_key_write"
5118                    | "insert_method"
5119                    | "data"
5120                    | "index"
5121                    | "encryption"
5122                    | "compression"
5123            );
5124            if !known {
5125                break;
5126            }
5127            self.advance(); // option name
5128            // `DEFAULT` optional prefix is followed by `CHARSET` /
5129            // `COLLATE`; consume the next ident too.
5130            if name_lc == "default" {
5131                if matches!(self.peek(), Token::Ident(_) | Token::QuotedIdent(_)) {
5132                    self.advance();
5133                }
5134            }
5135            if matches!(self.peek(), Token::Eq) {
5136                self.advance();
5137            }
5138            match self.peek() {
5139                Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_) | Token::Integer(_) => {
5140                    self.advance();
5141                }
5142                _ => {}
5143            }
5144        }
5145    }
5146
5147    /// v7.9.18 — true when the next tokens are `PRIMARY KEY (…)`.
5148    /// PRIMARY and KEY are bare idents; we look-ahead 2 to be
5149    /// sure (otherwise a column literally named `primary` would
5150    /// be mistaken).
5151    fn peek_table_level_pk_start(&self) -> bool {
5152        let cur = self.peek();
5153        let nxt = self.tokens.get(self.pos + 1);
5154        let nxt2 = self.tokens.get(self.pos + 2);
5155        let is_primary = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("primary"));
5156        let is_key = matches!(nxt, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("key"));
5157        let is_lparen = matches!(nxt2, Some(Token::LParen));
5158        is_primary && is_key && is_lparen
5159    }
5160
5161    /// v7.9.18 — true when the next tokens are `UNIQUE (…)`.
5162    /// v7.13.0 — also matches `UNIQUE NULLS [NOT] DISTINCT (…)`
5163    /// (mailrs round-5 G10).
5164    fn peek_table_level_unique_start(&self) -> bool {
5165        let cur = self.peek();
5166        let is_unique = matches!(cur, Token::Ident(s) if s.eq_ignore_ascii_case("unique"));
5167        if !is_unique {
5168            return false;
5169        }
5170        let n1 = self.tokens.get(self.pos + 1);
5171        // Plain `UNIQUE (…)`.
5172        if matches!(n1, Some(Token::LParen)) {
5173            return true;
5174        }
5175        // `UNIQUE NULLS [NOT] DISTINCT (…)`.
5176        let is_nulls = matches!(n1, Some(Token::Ident(s)) if s.eq_ignore_ascii_case("nulls"));
5177        if !is_nulls {
5178            return false;
5179        }
5180        let n2 = self.tokens.get(self.pos + 2);
5181        let n3 = self.tokens.get(self.pos + 3);
5182        let n4 = self.tokens.get(self.pos + 4);
5183        // `UNIQUE NULLS DISTINCT (…)` — 4 tokens before `(`.
5184        if matches!(n2, Some(Token::Distinct)) && matches!(n3, Some(Token::LParen)) {
5185            return true;
5186        }
5187        // `UNIQUE NULLS NOT DISTINCT (…)` — 5 tokens before `(`.
5188        if matches!(n2, Some(Token::Not))
5189            && matches!(n3, Some(Token::Distinct))
5190            && matches!(n4, Some(Token::LParen))
5191        {
5192            return true;
5193        }
5194        false
5195    }
5196
5197    fn parse_table_level_primary_key(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5198        self.advance(); // PRIMARY
5199        self.advance(); // KEY
5200        let columns = self.parse_paren_ident_list("PRIMARY KEY")?;
5201        Ok(crate::ast::TableConstraint::PrimaryKey {
5202            name: None,
5203            columns,
5204        })
5205    }
5206
5207    fn parse_table_level_unique(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5208        self.advance(); // UNIQUE
5209        // v7.13.0 — optional `NULLS NOT DISTINCT` modifier
5210        // (mailrs round-5 G10, PG 15+ surface). Default behaviour
5211        // is `NULLS DISTINCT` per the SQL standard.
5212        let mut nulls_not_distinct = false;
5213        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5214            let n1 = self.tokens.get(self.pos + 1);
5215            let n2 = self.tokens.get(self.pos + 2);
5216            let is_not = matches!(n1, Some(Token::Not));
5217            let is_distinct = matches!(n2, Some(Token::Distinct));
5218            if is_not && is_distinct {
5219                self.advance(); // NULLS
5220                self.advance(); // NOT
5221                self.advance(); // DISTINCT
5222                nulls_not_distinct = true;
5223            } else if matches!(n1, Some(Token::Distinct)) {
5224                self.advance(); // NULLS
5225                self.advance(); // DISTINCT
5226            }
5227        }
5228        let columns = self.parse_paren_ident_list("UNIQUE")?;
5229        Ok(crate::ast::TableConstraint::Unique {
5230            name: None,
5231            columns,
5232            nulls_not_distinct,
5233        })
5234    }
5235
5236    /// v7.13.0 — table-level `CHECK (<expr>)` constraint
5237    /// (mailrs round-5 G3). Consumes `CHECK` then a parenthesised
5238    /// expression.
5239    fn parse_table_level_check(&mut self) -> Result<crate::ast::TableConstraint, ParseError> {
5240        self.advance(); // CHECK
5241        if !matches!(self.peek(), Token::LParen) {
5242            return Err(self.err(alloc::format!(
5243                "expected '(' after CHECK, got {:?}",
5244                self.peek()
5245            )));
5246        }
5247        self.advance();
5248        let expr = self.parse_expr(0)?;
5249        if !matches!(self.peek(), Token::RParen) {
5250            return Err(self.err(alloc::format!(
5251                "expected ')' to close CHECK predicate, got {:?}",
5252                self.peek()
5253            )));
5254        }
5255        self.advance();
5256        Ok(crate::ast::TableConstraint::Check { name: None, expr })
5257    }
5258
5259    /// v7.13.0 — `true` when the next token is `CHECK` (a bare ident).
5260    fn peek_table_level_check_start(&self) -> bool {
5261        matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("check"))
5262    }
5263
5264    /// v7.22 (round-13 gap 5) — `Some(kind)` when the next tokens are
5265    /// `CONSTRAINT <name> { CHECK | UNIQUE | PRIMARY }`. FOREIGN stays
5266    /// on the dedicated FK path (`parse_table_level_fk` consumes its
5267    /// own CONSTRAINT prefix).
5268    fn peek_named_table_constraint_kind(&self) -> Option<NamedTableConstraintKind> {
5269        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5270            return None;
5271        }
5272        // tokens[pos+1] is the constraint name (any ident-like);
5273        // tokens[pos+2] is the kind keyword.
5274        match self.tokens.get(self.pos + 2) {
5275            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("check") => {
5276                Some(NamedTableConstraintKind::Check)
5277            }
5278            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("unique") => {
5279                Some(NamedTableConstraintKind::Unique)
5280            }
5281            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("primary") => {
5282                Some(NamedTableConstraintKind::PrimaryKey)
5283            }
5284            _ => None,
5285        }
5286    }
5287
5288    fn parse_paren_ident_list(&mut self, ctx: &str) -> Result<Vec<String>, ParseError> {
5289        if !matches!(self.peek(), Token::LParen) {
5290            return Err(self.err(alloc::format!(
5291                "expected '(' after {ctx}, got {:?}",
5292                self.peek()
5293            )));
5294        }
5295        self.advance();
5296        let mut out = Vec::new();
5297        loop {
5298            out.push(self.expect_ident_like()?);
5299            match self.peek() {
5300                Token::Comma => {
5301                    self.advance();
5302                }
5303                Token::RParen => {
5304                    self.advance();
5305                    break;
5306                }
5307                other => {
5308                    return Err(self.err(alloc::format!(
5309                        "expected ',' or ')' in {ctx} list, got {other:?}"
5310                    )));
5311                }
5312            }
5313        }
5314        if out.is_empty() {
5315            return Err(self.err(alloc::format!("{ctx} requires at least one column")));
5316        }
5317        Ok(out)
5318    }
5319
5320    /// v7.6.0 — true when the next tokens are `CONSTRAINT <name>
5321    /// FOREIGN KEY` or bare `FOREIGN KEY`. Both introduce a
5322    /// table-level FK; a column def never starts with either keyword
5323    /// (column names are not in this reserved set).
5324    fn peek_constraint_or_fk_start(&self) -> bool {
5325        let is_constraint_kw = matches!(
5326            self.peek(),
5327            Token::Ident(s) if s.eq_ignore_ascii_case("constraint")
5328        );
5329        let is_foreign_kw = matches!(
5330            self.peek(),
5331            Token::Ident(s) if s.eq_ignore_ascii_case("foreign")
5332        );
5333        is_constraint_kw || is_foreign_kw
5334    }
5335
5336    /// v7.6.0 — parse a table-level FK clause:
5337    /// `[CONSTRAINT <name>] FOREIGN KEY (<col>[,<col>]*) REFERENCES
5338    /// <tbl> [(<pcol>[,<pcol>]*)] [ON DELETE <action>] [ON UPDATE <action>]`.
5339    fn parse_table_level_fk(&mut self) -> Result<ForeignKeyConstraint, ParseError> {
5340        let mut name: Option<String> = None;
5341        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
5342            self.advance();
5343            name = Some(self.expect_ident_like()?);
5344        }
5345        // `FOREIGN`
5346        match self.advance() {
5347            Token::Ident(s) if s.eq_ignore_ascii_case("foreign") => {}
5348            other => return Err(self.err(format!("expected FOREIGN, got {other:?}"))),
5349        }
5350        // `KEY`
5351        match self.advance() {
5352            Token::Ident(s) if s.eq_ignore_ascii_case("key") => {}
5353            other => return Err(self.err(format!("expected KEY after FOREIGN, got {other:?}"))),
5354        }
5355        // `(col, col, ...)`
5356        if !matches!(self.peek(), Token::LParen) {
5357            return Err(self.err(format!(
5358                "expected '(' after FOREIGN KEY, got {:?}",
5359                self.peek()
5360            )));
5361        }
5362        self.advance();
5363        let mut columns = Vec::new();
5364        loop {
5365            columns.push(self.expect_ident_like()?);
5366            match self.peek() {
5367                Token::Comma => {
5368                    self.advance();
5369                }
5370                Token::RParen => {
5371                    self.advance();
5372                    break;
5373                }
5374                other => {
5375                    return Err(self.err(format!(
5376                        "expected ',' or ')' in FK column list, got {other:?}"
5377                    )));
5378                }
5379            }
5380        }
5381        if columns.is_empty() {
5382            return Err(self.err("FOREIGN KEY requires at least one column".into()));
5383        }
5384        let (parent_table, parent_columns, on_delete, on_update) =
5385            self.parse_references_tail(columns.len())?;
5386        Ok(ForeignKeyConstraint {
5387            name,
5388            columns,
5389            parent_table,
5390            parent_columns,
5391            on_delete,
5392            on_update,
5393        })
5394    }
5395
5396    /// v7.6.0 — parse the tail `REFERENCES <tbl> [(<pcol>...)] [ON
5397    /// DELETE <action>] [ON UPDATE <action>]`. `expected_arity` is
5398    /// the local column count, used to default the parent column
5399    /// list when omitted (SQL spec: parent's PK is implied).
5400    fn parse_references_tail(
5401        &mut self,
5402        expected_arity: usize,
5403    ) -> Result<(String, Vec<String>, FkAction, FkAction), ParseError> {
5404        match self.advance() {
5405            Token::Ident(s) if s.eq_ignore_ascii_case("references") => {}
5406            other => return Err(self.err(format!("expected REFERENCES, got {other:?}"))),
5407        }
5408        let parent_table = self.expect_ident_like()?;
5409        let mut parent_columns: Vec<String> = Vec::new();
5410        if matches!(self.peek(), Token::LParen) {
5411            self.advance();
5412            loop {
5413                parent_columns.push(self.expect_ident_like()?);
5414                match self.peek() {
5415                    Token::Comma => {
5416                        self.advance();
5417                    }
5418                    Token::RParen => {
5419                        self.advance();
5420                        break;
5421                    }
5422                    other => {
5423                        return Err(self.err(format!(
5424                            "expected ',' or ')' in REFERENCES column list, got {other:?}"
5425                        )));
5426                    }
5427                }
5428            }
5429        }
5430        if !parent_columns.is_empty() && parent_columns.len() != expected_arity {
5431            return Err(self.err(format!(
5432                "FK arity mismatch: {} local column(s) vs {} parent column(s)",
5433                expected_arity,
5434                parent_columns.len()
5435            )));
5436        }
5437        // v7.6.7 / v7.17.0 Phase 3.1 — interleave `[NOT] DEFERRABLE
5438        // [INITIALLY {DEFERRED | IMMEDIATE}]` and `ON DELETE
5439        // <action>` / `ON UPDATE <action>` in either order. PG /
5440        // pg_dump emits the timing clause AFTER the ON clauses
5441        // (`ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED`),
5442        // but the SQL spec allows either order. We loop over
5443        // every possible trailer and dispatch on the next token,
5444        // stopping when nothing matches. Phase 3.1 changes the
5445        // bare DEFERRABLE form from hard-error to accept-as-
5446        // immediate; SPG is single-writer with no deferred-
5447        // constraint window so the runtime semantics are always
5448        // immediate even when INITIALLY DEFERRED is requested.
5449        let mut on_delete = FkAction::Restrict;
5450        let mut on_update = FkAction::Restrict;
5451        let mut seen_on_delete = false;
5452        let mut seen_on_update = false;
5453        loop {
5454            // DEFERRABLE / NOT DEFERRABLE / INITIALLY shapes.
5455            let before = self.pos;
5456            self.consume_optional_deferrable_clauses()?;
5457            if self.pos != before {
5458                continue;
5459            }
5460            // ON DELETE / ON UPDATE.
5461            if !matches!(self.peek(), Token::On) {
5462                break;
5463            }
5464            self.advance();
5465            let which = self.advance();
5466            let action = self.parse_fk_action()?;
5467            match which {
5468                Token::Ident(ref s) if s.eq_ignore_ascii_case("delete") => {
5469                    if seen_on_delete {
5470                        return Err(self.err("ON DELETE specified twice".into()));
5471                    }
5472                    seen_on_delete = true;
5473                    on_delete = action;
5474                }
5475                Token::Ident(ref s) if s.eq_ignore_ascii_case("update") => {
5476                    if seen_on_update {
5477                        return Err(self.err("ON UPDATE specified twice".into()));
5478                    }
5479                    seen_on_update = true;
5480                    on_update = action;
5481                }
5482                other => {
5483                    return Err(
5484                        self.err(format!("expected DELETE or UPDATE after ON, got {other:?}"))
5485                    );
5486                }
5487            }
5488        }
5489        Ok((parent_table, parent_columns, on_delete, on_update))
5490    }
5491
5492    /// v7.6.0 — parse `CASCADE | RESTRICT | SET NULL | SET DEFAULT |
5493    /// NO ACTION`.
5494    fn parse_fk_action(&mut self) -> Result<FkAction, ParseError> {
5495        match self.advance() {
5496            Token::Ident(s) if s.eq_ignore_ascii_case("cascade") => Ok(FkAction::Cascade),
5497            Token::Ident(s) if s.eq_ignore_ascii_case("restrict") => Ok(FkAction::Restrict),
5498            Token::Ident(s) if s.eq_ignore_ascii_case("set") => match self.advance() {
5499                Token::Null => Ok(FkAction::SetNull),
5500                Token::Default => Ok(FkAction::SetDefault),
5501                other => Err(self.err(format!(
5502                    "expected NULL or DEFAULT after SET in FK action, got {other:?}"
5503                ))),
5504            },
5505            Token::Ident(s) if s.eq_ignore_ascii_case("no") => match self.advance() {
5506                Token::Ident(s) if s.eq_ignore_ascii_case("action") => Ok(FkAction::NoAction),
5507                other => Err(self.err(format!(
5508                    "expected ACTION after NO in FK action, got {other:?}"
5509                ))),
5510            },
5511            other => Err(self.err(format!(
5512                "expected CASCADE | RESTRICT | SET NULL | SET DEFAULT | NO ACTION, got {other:?}"
5513            ))),
5514        }
5515    }
5516
5517    /// Recognise the optional `IF NOT EXISTS` prefix shared by `CREATE
5518    /// TABLE` and `CREATE INDEX`. Returns `true` if consumed.
5519    fn consume_if_not_exists(&mut self) -> bool {
5520        // `IF` arrives as a bare Ident (we don't reserve it because it
5521        // also appears mid-expression in PG, though we don't support
5522        // those forms yet).
5523        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5524        if !looks_like_if {
5525            return false;
5526        }
5527        // Peek one ahead before committing: only consume IF when it's
5528        // actually `IF NOT EXISTS`.
5529        if !matches!(self.tokens.get(self.pos + 1), Some(Token::Not)) {
5530            return false;
5531        }
5532        if !matches!(
5533            self.tokens.get(self.pos + 2),
5534            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5535        ) {
5536            return false;
5537        }
5538        self.advance(); // IF
5539        self.advance(); // NOT
5540        self.advance(); // EXISTS
5541        true
5542    }
5543
5544    /// v7.12.4 — `IF EXISTS` modifier for DROP statements.
5545    /// Consumes IF EXISTS as a pair; returns false otherwise
5546    /// without consuming any tokens.
5547    fn consume_if_exists(&mut self) -> bool {
5548        let looks_like_if = matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("if"));
5549        if !looks_like_if {
5550            return false;
5551        }
5552        if !matches!(
5553            self.tokens.get(self.pos + 1),
5554            Some(Token::Ident(s)) if s.eq_ignore_ascii_case("exists")
5555        ) {
5556            return false;
5557        }
5558        self.advance(); // IF
5559        self.advance(); // EXISTS
5560        true
5561    }
5562
5563    /// v7.9.14 — consume `ASC | DESC | NULLS FIRST | NULLS LAST`
5564    /// qualifiers after an index column ref. ASC / DESC are
5565    /// reserved tokens; NULLS / FIRST / LAST are bare idents.
5566    /// We accept and discard them since single-column BTree
5567    /// stores rows in natural key order today.
5568    /// v7.24 (round-16 A) — `NULLS FIRST` / `NULLS LAST` after an
5569    /// ORDER BY key. Returns None when absent.
5570    fn parse_optional_nulls_placement(&mut self) -> Result<Option<bool>, ParseError> {
5571        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("nulls")) {
5572            return Ok(None);
5573        }
5574        self.advance();
5575        match self.advance() {
5576            Token::Ident(s) if s.eq_ignore_ascii_case("first") => Ok(Some(true)),
5577            Token::Ident(s) if s.eq_ignore_ascii_case("last") => Ok(Some(false)),
5578            other => Err(self.err(alloc::format!(
5579                "expected FIRST or LAST after NULLS, got {other:?}"
5580            ))),
5581        }
5582    }
5583
5584    fn consume_optional_index_column_qualifiers(&mut self) {
5585        loop {
5586            match self.peek() {
5587                Token::Asc | Token::Desc => {
5588                    self.advance();
5589                }
5590                Token::Ident(s) if s.eq_ignore_ascii_case("nulls") => {
5591                    let look = self.tokens.get(self.pos + 1);
5592                    if matches!(
5593                        look,
5594                        Some(Token::Ident(k)) if k.eq_ignore_ascii_case("first")
5595                            || k.eq_ignore_ascii_case("last")
5596                    ) {
5597                        self.advance();
5598                        self.advance();
5599                    } else {
5600                        break;
5601                    }
5602                }
5603                _ => break,
5604            }
5605        }
5606    }
5607
5608    fn parse_create_index_stmt_after_create(
5609        &mut self,
5610        is_unique: bool,
5611    ) -> Result<Statement, ParseError> {
5612        // Caller consumed CREATE (and the optional UNIQUE); we're on INDEX.
5613        debug_assert!(matches!(self.peek(), Token::Index));
5614        self.advance();
5615        let if_not_exists = self.consume_if_not_exists();
5616        let name = self.expect_ident_like()?;
5617        if !matches!(self.peek(), Token::On) {
5618            return Err(self.err(format!(
5619                "expected ON after CREATE INDEX <name>, got {:?}",
5620                self.peek()
5621            )));
5622        }
5623        self.advance();
5624        let table = self.expect_ident_like()?;
5625        // Optional `USING <method>` — only recognised method in v2.0 is
5626        // `hnsw` (a single-layer NSW graph for kNN). `USING` is the bare
5627        // ident `using` (we don't promote it to a reserved keyword
5628        // because it isn't reserved anywhere else in our SQL surface).
5629        let method = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
5630            self.advance();
5631            let m = self.expect_ident_like()?;
5632            match m.to_ascii_lowercase().as_str() {
5633                "hnsw" => IndexMethod::Hnsw,
5634                "btree" => IndexMethod::BTree,
5635                "brin" => IndexMethod::Brin,
5636                // v7.12.3 — real GIN inverted index over `tsvector`.
5637                // v7.9.26b's `USING gin` → BTree silent fallback is
5638                // gone; the engine validates that the indexed column
5639                // is `tsvector` at CREATE INDEX time.
5640                "gin" => IndexMethod::Gin,
5641                // v7.9.26b — PG `pg_dump` emits `USING gist` /
5642                // `USING spgist` / `USING hash` for their built-in
5643                // AMs that SPG doesn't have a matching
5644                // implementation for; degrade to BTree on the
5645                // leading column so the schema loads + the index
5646                // catalogue stays consistent. Operator pays the
5647                // planner cost only for the queries that would have
5648                // used the specialised AM.
5649                "gist" | "spgist" | "hash" => IndexMethod::BTree,
5650                // v7.11.3 — pgvector ships both `ivfflat` and
5651                // `hnsw`. Customers shouldn't have to choose
5652                // their on-disk index method based on what SPG
5653                // implements; accept `ivfflat` as a synonym for
5654                // `hnsw` so PG schemas using either method drop
5655                // in. The vector distance op (`<->` / `<#>` /
5656                // `<=>`) at query time still picks the metric.
5657                "ivfflat" => IndexMethod::Hnsw,
5658                other => {
5659                    return Err(self.err(alloc::format!(
5660                        "unknown index method {other:?}; supported: hnsw, btree, brin, gin (gist/spgist/hash accepted as BTree fallback)"
5661                    )));
5662                }
5663            }
5664        } else {
5665            IndexMethod::BTree
5666        };
5667        if !matches!(self.peek(), Token::LParen) {
5668            return Err(self.err(format!(
5669                "expected '(' before indexed column, got {:?}",
5670                self.peek()
5671            )));
5672        }
5673        self.advance();
5674        // v6.8.2 — accept either a bare column ident (legacy) or
5675        // an expression `fn(col, …)` for expression indexes.
5676        // Distinguish by peeking the token *after* the current
5677        // ident: `ident )` is the legacy column-only path;
5678        // anything else triggers the Pratt expression parser.
5679        // (`advance()` uses `mem::replace` to nil out the current
5680        // slot, so we can't save+rewind cleanly — peek-ahead via
5681        // direct index avoids the mutation.)
5682        let mut opclass: Option<String> = None;
5683        let (column, expression): (String, Option<Expr>) = match self.peek().clone() {
5684            // Single column with `)` immediately after — fast path.
5685            // v7.9.29 — also: bare column followed by `,` (the
5686            // multi-column form `(a, b, c)`). Without this branch
5687            // the leading ident gets pulled into `parse_expr`
5688            // which then sets `expression = Some(Column(a))` and
5689            // breaks Display round-trip on the multi-column shape.
5690            Token::Ident(s) | Token::QuotedIdent(s)
5691                if matches!(
5692                    self.tokens.get(self.pos + 1),
5693                    Some(Token::RParen | Token::Comma)
5694                ) =>
5695            {
5696                self.advance();
5697                (s, None)
5698            }
5699            // v7.9.22 — single column followed by a pgvector
5700            // opclass ident: `(col vector_cosine_ops)`. mailrs G5.
5701            // v7.15.0 — capture the opclass instead of discarding
5702            // it so the engine can dispatch (e.g. `gin_trgm_ops`
5703            // → real trigram-shingle GIN over a TEXT column).
5704            // Vector/HNSW opclasses still take their distance
5705            // metric from the query operator (`<->` / `<#>` /
5706            // `<=>`), so for those callers the opclass stays
5707            // informational.
5708            // v7.22 (mailrs round-13 gap 7) — pg_dump qualifies the
5709            // opclass: `(embedding public.vector_cosine_ops)`. Strip
5710            // the schema and dispatch on the bare opclass, the same
5711            // treatment table/type names get.
5712            Token::Ident(s) | Token::QuotedIdent(s)
5713                if matches!(
5714                    self.tokens.get(self.pos + 1),
5715                    Some(Token::Ident(_) | Token::QuotedIdent(_))
5716                ) && matches!(self.tokens.get(self.pos + 2), Some(Token::Dot))
5717                    && matches!(
5718                        self.tokens.get(self.pos + 3),
5719                        Some(Token::Ident(op) | Token::QuotedIdent(op))
5720                            if is_vector_opclass_name(op)
5721                    ) =>
5722            {
5723                self.advance(); // column name
5724                self.advance(); // schema qualifier
5725                self.advance(); // dot
5726                let op_tok = self.advance();
5727                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5728                    opclass = Some(op.to_ascii_lowercase());
5729                }
5730                (s, None)
5731            }
5732            Token::Ident(s) | Token::QuotedIdent(s)
5733                if matches!(
5734                    self.tokens.get(self.pos + 1),
5735                    Some(Token::Ident(op) | Token::QuotedIdent(op))
5736                        if is_vector_opclass_name(op)
5737                ) =>
5738            {
5739                self.advance(); // column name
5740                // Capture the opclass token, lower-cased for
5741                // case-insensitive engine dispatch.
5742                let op_tok = self.advance();
5743                if let Token::Ident(op) | Token::QuotedIdent(op) = op_tok {
5744                    opclass = Some(op.to_ascii_lowercase());
5745                }
5746                (s, None)
5747            }
5748            Token::Ident(_) | Token::QuotedIdent(_) => {
5749                let key_expr = self.parse_expr(0)?;
5750                let primary = extract_first_column(&key_expr).ok_or_else(|| {
5751                    self.err("expression index key must reference at least one column".into())
5752                })?;
5753                (primary, Some(key_expr))
5754            }
5755            other => {
5756                return Err(self.err(format!(
5757                    "expected column ident or expression, got {other:?}"
5758                )));
5759            }
5760        };
5761        // v7.9.14 — accept extra comma-separated columns inside
5762        // the index key parens (`CREATE INDEX … (a, b, c)`).
5763        // mailrs F2. Each extra column may carry an optional
5764        // `ASC` / `DESC` / `NULLS FIRST` / `NULLS LAST` clause
5765        // — parsed and discarded; SPG doesn't honour direction
5766        // on a BTree index today (column ordering is intrinsic
5767        // to the storage). v7.10 will widen to genuine composite
5768        // index keys.
5769        let mut extra_columns: Vec<String> = Vec::new();
5770        // The leading column may also have ASC/DESC after it.
5771        self.consume_optional_index_column_qualifiers();
5772        while matches!(self.peek(), Token::Comma) {
5773            self.advance();
5774            let extra = self.expect_ident_like()?;
5775            self.consume_optional_index_column_qualifiers();
5776            extra_columns.push(extra);
5777        }
5778        if !matches!(self.peek(), Token::RParen) {
5779            return Err(self.err(format!(
5780                "expected ')' after indexed column / expression, got {:?}",
5781                self.peek()
5782            )));
5783        }
5784        self.advance();
5785        // v6.8.0 — optional `INCLUDE (col1, col2, …)` clause for
5786        // index-only-scan annotation. Bare ident (not a reserved
5787        // keyword) so we test by case-insensitive string match.
5788        let included_columns = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("include"))
5789        {
5790            self.advance();
5791            if !matches!(self.peek(), Token::LParen) {
5792                return Err(self.err(format!("expected '(' after INCLUDE, got {:?}", self.peek())));
5793            }
5794            self.advance();
5795            let mut cols = Vec::new();
5796            loop {
5797                cols.push(self.expect_ident_like()?);
5798                match self.peek() {
5799                    Token::Comma => {
5800                        self.advance();
5801                    }
5802                    Token::RParen => {
5803                        self.advance();
5804                        break;
5805                    }
5806                    other => {
5807                        return Err(self.err(format!(
5808                            "expected ',' or ')' in INCLUDE list, got {other:?}"
5809                        )));
5810                    }
5811                }
5812            }
5813            cols
5814        } else {
5815            Vec::new()
5816        };
5817        // v7.11.3 — accept and discard PG `WITH (k = v, ...)` index
5818        // storage parameters. pgvector emits `WITH (lists = N)` for
5819        // ivfflat and `WITH (m = N, ef_construction = M)` for hnsw;
5820        // SPG's HNSW picks its own parameters today (tunable via
5821        // env vars), so the WITH clause is informational and dropped.
5822        if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with")) {
5823            self.advance();
5824            if !matches!(self.peek(), Token::LParen) {
5825                return Err(self.err(format!(
5826                    "expected '(' after WITH in CREATE INDEX, got {:?}",
5827                    self.peek()
5828                )));
5829            }
5830            self.advance();
5831            loop {
5832                if matches!(self.peek(), Token::RParen) {
5833                    self.advance();
5834                    break;
5835                }
5836                // Drain `key = value` or bare `key` tokens.
5837                let _ = self.advance(); // key
5838                if matches!(self.peek(), Token::Eq) {
5839                    self.advance();
5840                    let _ = self.advance(); // value (int / string / ident)
5841                }
5842                match self.peek() {
5843                    Token::Comma => {
5844                        self.advance();
5845                    }
5846                    Token::RParen => {
5847                        self.advance();
5848                        break;
5849                    }
5850                    other => {
5851                        return Err(self.err(format!(
5852                            "expected ',' or ')' in WITH (…) clause, got {other:?}"
5853                        )));
5854                    }
5855                }
5856            }
5857        }
5858        // v6.8.1 — optional `WHERE <expr>` partial-index predicate.
5859        let partial_predicate = if matches!(self.peek(), Token::Where) {
5860            self.advance();
5861            Some(self.parse_expr(0)?)
5862        } else {
5863            None
5864        };
5865        // v7.9.29 — UNIQUE on a vector index (HNSW) makes no
5866        // sense: uniqueness over an ANN structure has no clean
5867        // semantics. Reject early. (BRIN UNIQUE is similarly
5868        // meaningless — block both.)
5869        if is_unique && !matches!(method, IndexMethod::BTree) {
5870            return Err(self.err(alloc::format!(
5871                "UNIQUE is only supported on BTree indexes, got USING {:?}",
5872                method
5873            )));
5874        }
5875        Ok(Statement::CreateIndex(CreateIndexStatement {
5876            name,
5877            table,
5878            column,
5879            method,
5880            if_not_exists,
5881            included_columns,
5882            partial_predicate,
5883            extra_columns: extra_columns.clone(),
5884            expression,
5885            is_unique,
5886            opclass,
5887        }))
5888    }
5889
5890    /// v7.6.0 — wraps `parse_column_def` and consumes an optional
5891    /// column-level `REFERENCES ...` clause. The trailing FK is
5892    /// normalised into table-level shape (single-element columns +
5893    /// parent_columns) so the engine sees one uniform constraint list.
5894    fn parse_column_def_with_fk(
5895        &mut self,
5896    ) -> Result<(ColumnDef, Option<ForeignKeyConstraint>), ParseError> {
5897        let col = self.parse_column_def()?;
5898        // Inline form: `col INT REFERENCES tbl(pcol) [ON DELETE ...] [ON UPDATE ...]`.
5899        let inline_references = matches!(
5900            self.peek(),
5901            Token::Ident(s) if s.eq_ignore_ascii_case("references")
5902        );
5903        if !inline_references {
5904            return Ok((col, None));
5905        }
5906        let (parent_table, parent_columns, on_delete, on_update) = self.parse_references_tail(1)?;
5907        let fk = ForeignKeyConstraint {
5908            name: None,
5909            columns: vec![col.name.clone()],
5910            parent_table,
5911            parent_columns,
5912            on_delete,
5913            on_update,
5914        };
5915        Ok((col, Some(fk)))
5916    }
5917
5918    /// v7.13.0 — parse a column type (consuming the type ident and
5919    /// any trailing parameters / `[]`), without surrounding column
5920    /// constraints. Used by ALTER COLUMN TYPE (mailrs round-5 G8).
5921    /// Returns the resolved `ColumnTypeName` plus implied
5922    /// `(auto_increment, not_null)` flags from PG SERIAL family
5923    /// shorthands — callers that don't expect those (ALTER COLUMN
5924    /// TYPE) can discard them.
5925    fn parse_column_type_name(&mut self) -> Result<ColumnTypeName, ParseError> {
5926        let (ty, _, _, _, _, _, _, _) = self.parse_type_with_implied_flags()?;
5927        Ok(ty)
5928    }
5929
5930    #[allow(clippy::type_complexity)]
5931    fn parse_type_with_implied_flags(
5932        &mut self,
5933    ) -> Result<
5934        (
5935            ColumnTypeName,
5936            bool,
5937            bool,
5938            Option<String>,
5939            Collation,
5940            bool,
5941            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant
5942            // list captured at type-parse time. None for all
5943            // non-ENUM types.
5944            Option<Vec<String>>,
5945            // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant
5946            // list. Distinct from ENUM (subset semantics).
5947            Option<Vec<String>>,
5948        ),
5949        ParseError,
5950    > {
5951        let mut ty_ident = match self.advance() {
5952            Token::Ident(s) => s,
5953            other => {
5954                return Err(ParseError {
5955                    message: format!("expected column type, got {other:?}"),
5956                    token_pos: self.pos.saturating_sub(1),
5957                });
5958            }
5959        };
5960        // v7.22 (mailrs round-13 gap 4) — schema-qualified type names:
5961        // pg_dump qualifies extension types (`public.vector(1024)`).
5962        // SPG is single-namespace; drop the schema and resolve the
5963        // bare type — same treatment table names already get.
5964        while matches!(self.peek(), Token::Dot) {
5965            self.advance();
5966            ty_ident = self.expect_ident_like()?;
5967        }
5968        let mut implied_auto_increment = false;
5969        let mut implied_not_null = false;
5970        let mut user_type_ref: Option<String> = None;
5971        // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM('a','b','c')
5972        // value list, captured here and bubbled up through the
5973        // ColumnDef so the engine can attach it to the column
5974        // schema (and validate INSERT cells against it).
5975        let mut inline_enum_variants: Option<Vec<String>> = None;
5976        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
5977        let mut inline_set_variants: Option<Vec<String>> = None;
5978        let mut ty = match ty_ident.as_str() {
5979            // PG SERIAL family. Implies NOT NULL + AUTO_INCREMENT.
5980            "smallserial" | "serial2" => {
5981                implied_auto_increment = true;
5982                implied_not_null = true;
5983                ColumnTypeName::SmallInt
5984            }
5985            "serial" | "serial4" => {
5986                implied_auto_increment = true;
5987                implied_not_null = true;
5988                ColumnTypeName::Int
5989            }
5990            "bigserial" | "serial8" => {
5991                implied_auto_increment = true;
5992                implied_not_null = true;
5993                ColumnTypeName::BigInt
5994            }
5995            // MySQL flavours we accept by aliasing to the closest SPG
5996            // type. TINYINT covers MySQL's i8 — held inside SMALLINT
5997            // since SPG doesn't have a dedicated i8. MEDIUMINT (MySQL
5998            // 24-bit) → INT. UNSIGNED modifiers are consumed below
5999            // without semantic effect.
6000            "smallint" => {
6001                // v7.14.0 — MySQL display-width on integers
6002                // (`SMALLINT(5)`, `INT(11)`, `BIGINT(20)`). The
6003                // parenthesised number is purely cosmetic — it
6004                // doesn't change storage. Accept + discard.
6005                self.consume_optional_paren_size();
6006                ColumnTypeName::SmallInt
6007            }
6008            // v7.17.0 Phase 4.3 — MySQL `TINYINT(1)` is the
6009            // canonical encoding for BOOLEAN. Every MySQL driver
6010            // (JDBC `tinyInt1isBit=true`, PHP `mysql_field_type`,
6011            // .NET `MySqlConnection`, sqlx) maps it to bit. Pre-
6012            // 4.3 SPG classified TINYINT(1) as SmallInt, which
6013            // gave the customer i16-shaped values where the app
6014            // expected bool — a Tier-A silent type drift on
6015            // mysqldump restores. Now: `TINYINT(1)` → Bool;
6016            // `TINYINT` (no width) and `TINYINT(N)` for N ≠ 1
6017            // stay SmallInt (the legacy width-agnostic path).
6018            "tinyint" => {
6019                let width = self.peek_optional_paren_size_value();
6020                self.consume_optional_paren_size();
6021                if width == Some(1) {
6022                    ColumnTypeName::Bool
6023                } else {
6024                    ColumnTypeName::SmallInt
6025                }
6026            }
6027            "int" | "integer" | "mediumint" => {
6028                self.consume_optional_paren_size();
6029                ColumnTypeName::Int
6030            }
6031            "bigint" => {
6032                self.consume_optional_paren_size();
6033                ColumnTypeName::BigInt
6034            }
6035            // DOUBLE / REAL are 64-bit IEEE — same as our FLOAT.
6036            // v7.13.0 — `DOUBLE PRECISION` (PG canonical spelling)
6037            // (mailrs round-5 G6). Consume the optional `PRECISION`
6038            // tail when the type keyword was `double` / `DOUBLE`.
6039            "float" | "double" | "real" => {
6040                if ty_ident.eq_ignore_ascii_case("double")
6041                    && matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("precision"))
6042                {
6043                    self.advance();
6044                }
6045                ColumnTypeName::Float
6046            }
6047            // v7.13.0 — `FLOAT8` (PG short form) maps the same as FLOAT.
6048            "float4" | "float8" => ColumnTypeName::Float,
6049            "text" => ColumnTypeName::Text,
6050            "bool" | "boolean" => ColumnTypeName::Bool,
6051            "varchar" => ColumnTypeName::Varchar(self.parse_paren_size("VARCHAR")?),
6052            "char" => ColumnTypeName::Char(self.parse_paren_size("CHAR")?),
6053            "vector" => {
6054                let dim = self.parse_paren_size("VECTOR")?;
6055                let encoding = self.parse_optional_vector_encoding()?;
6056                ColumnTypeName::Vector { dim, encoding }
6057            }
6058            "numeric" => {
6059                let (precision, scale) = self.parse_optional_numeric_params()?;
6060                ColumnTypeName::Numeric(precision, scale)
6061            }
6062            "date" => ColumnTypeName::Date,
6063            // MySQL's `DATETIME` is the same domain as standard
6064            // `TIMESTAMP` — accept both spellings.
6065            "timestamp" | "datetime" => {
6066                // v7.14.0 — PG canonical `TIMESTAMP WITH TIME ZONE`
6067                // / `TIMESTAMP WITHOUT TIME ZONE`. pg_dump emits
6068                // the full form. SPG canonicalises:
6069                //   - WITH TIME ZONE    → Timestamptz
6070                //   - WITHOUT TIME ZONE → Timestamp
6071                if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("with"))
6072                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6073                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6074                {
6075                    self.advance(); // WITH
6076                    self.advance(); // TIME
6077                    self.advance(); // ZONE
6078                    ColumnTypeName::Timestamptz
6079                } else if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("without"))
6080                    && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("time"))
6081                    && matches!(self.tokens.get(self.pos + 2), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("zone"))
6082                {
6083                    self.advance(); // WITHOUT
6084                    self.advance(); // TIME
6085                    self.advance(); // ZONE
6086                    ColumnTypeName::Timestamp
6087                } else {
6088                    // Optional `(precision)` parenthesised modifier
6089                    // (PG fractional seconds precision). SPG stores
6090                    // µs always; accept + discard.
6091                    self.consume_optional_paren_size();
6092                    ColumnTypeName::Timestamp
6093                }
6094            }
6095            // v7.9.2 — `TIMESTAMPTZ` and full PG spelling
6096            // `TIMESTAMP WITH TIME ZONE`. Same storage as TIMESTAMP;
6097            // only PG-wire OID differs.
6098            "timestamptz" => ColumnTypeName::Timestamptz,
6099            // v4.9: JSON / JSONB. Stored as raw text — no parse-time
6100            // validation. We accept the JSONB spelling too because
6101            // most PG clients default to it; SPG doesn't distinguish
6102            // the two (no path-operator perf advantage to model).
6103            "json" => ColumnTypeName::Json,
6104            "jsonb" => ColumnTypeName::Jsonb,
6105            // v7.10.4 — PG `BYTEA` and the SPG `BYTES` alias both
6106            // surface here. Same storage shape; mapping happens at
6107            // the engine side via the ColumnTypeName → DataType
6108            // resolver. Literal forms are handled at coerce_value
6109            // time so the lexer stays untouched.
6110            "bytea" | "bytes" => ColumnTypeName::Bytes,
6111            // v7.17.0 Phase 7 — PG network address types
6112            // (`inet`, `cidr`, `macaddr`). pg_dump / Django ORM
6113            // emit these for IP-address columns; pre-7 SPG
6114            // errored with "unknown type", breaking schema
6115            // restores. Accept as Text-backed (no new storage
6116            // shape): the customer's data round-trips, and the
6117            // host() / network() helpers added in the same
6118            // phase work textually. Containment operators
6119            // `<<=` / `>>=` are out of v7.17 scope.
6120            "inet" | "cidr" | "macaddr" => ColumnTypeName::Text,
6121            // v7.12.0 — PG full-text search types. mailrs G-CRIT-3.
6122            // The actual `to_tsvector` / `@@` / `ts_rank` surface
6123            // arrives in v7.12.1+; the type itself loads here so
6124            // mailrs's `scripts/init-schema.sql` runs unmodified.
6125            "tsvector" => ColumnTypeName::TsVector,
6126            "tsquery" => ColumnTypeName::TsQuery,
6127            // v7.17.0 — PG `UUID`. Wire OID 2950. The drop-in PG
6128            // surface for Django / Rails / Hibernate's default
6129            // PK pattern.
6130            "uuid" => ColumnTypeName::Uuid,
6131            // v7.17.0 Phase 3.P0-32 — PG `TIME` (without time zone).
6132            // i64 microseconds since 00:00:00. Wire OID 1083.
6133            "time" => ColumnTypeName::Time,
6134            // v7.17.0 Phase 3.P0-33 — MySQL `YEAR`. u16 in
6135            // 1901..=2155 + zero-year sentinel 0. Wire = INT4.
6136            "year" => ColumnTypeName::Year,
6137            // v7.17.0 Phase 3.P0-34 — PG `TIMETZ` / `TIME WITH
6138            // TIME ZONE`. i64 us + i32 offset_secs. Wire OID 1266.
6139            "timetz" => ColumnTypeName::TimeTz,
6140            // v7.17.0 Phase 3.P0-35 — PG `MONEY` — i64 cents.
6141            // Wire OID 790.
6142            "money" => ColumnTypeName::Money,
6143            // v7.17.0 Phase 3.P0-38 — PG range types.
6144            "int4range" => ColumnTypeName::Range(RangeKindAst::Int4),
6145            "int8range" => ColumnTypeName::Range(RangeKindAst::Int8),
6146            "numrange" => ColumnTypeName::Range(RangeKindAst::Num),
6147            "tsrange" => ColumnTypeName::Range(RangeKindAst::Ts),
6148            "tstzrange" => ColumnTypeName::Range(RangeKindAst::TsTz),
6149            "daterange" => ColumnTypeName::Range(RangeKindAst::Date),
6150            // v7.17.0 Phase 3.P0-39 — PG hstore extension type.
6151            "hstore" => ColumnTypeName::Hstore,
6152            // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6153            // `ENUM('a','b','c')`. Storage is TEXT; the value
6154            // list lands on `inline_enum_variants` for the
6155            // engine to validate INSERT cells against. Empty
6156            // value list is a parse error (matches MySQL).
6157            "enum" => {
6158                // Expect the opening `(`.
6159                if !matches!(self.peek(), Token::LParen) {
6160                    return Err(self.err(alloc::format!(
6161                        "expected '(' after ENUM, got {:?}",
6162                        self.peek()
6163                    )));
6164                }
6165                self.advance();
6166                let mut variants: Vec<String> = Vec::new();
6167                loop {
6168                    match self.advance() {
6169                        Token::String(s) => variants.push(s),
6170                        other => {
6171                            return Err(self.err(alloc::format!(
6172                                "ENUM(...) expects string literal variants, got {other:?}"
6173                            )));
6174                        }
6175                    }
6176                    match self.peek() {
6177                        Token::Comma => {
6178                            self.advance();
6179                            continue;
6180                        }
6181                        Token::RParen => {
6182                            self.advance();
6183                            break;
6184                        }
6185                        other => {
6186                            return Err(self.err(alloc::format!(
6187                                "expected ',' or ')' in ENUM(...), got {other:?}"
6188                            )));
6189                        }
6190                    }
6191                }
6192                if variants.is_empty() {
6193                    return Err(self.err("ENUM(...) must declare at least one variant".into()));
6194                }
6195                inline_enum_variants = Some(variants);
6196                // Storage is plain TEXT; the variant list lives on
6197                // the ColumnSchema side.
6198                ColumnTypeName::Text
6199            }
6200            // v7.17.0 Phase 3.P0-37 — MySQL inline SET
6201            // `SET('a','b','c')`. Same parse shape as ENUM;
6202            // semantics differ (subset rather than pick-one).
6203            "set" => {
6204                if !matches!(self.peek(), Token::LParen) {
6205                    return Err(self.err(alloc::format!(
6206                        "expected '(' after SET, got {:?}",
6207                        self.peek()
6208                    )));
6209                }
6210                self.advance();
6211                let mut variants: Vec<String> = Vec::new();
6212                loop {
6213                    match self.advance() {
6214                        Token::String(s) => variants.push(s),
6215                        other => {
6216                            return Err(self.err(alloc::format!(
6217                                "SET(...) expects string literal variants, got {other:?}"
6218                            )));
6219                        }
6220                    }
6221                    match self.peek() {
6222                        Token::Comma => {
6223                            self.advance();
6224                            continue;
6225                        }
6226                        Token::RParen => {
6227                            self.advance();
6228                            break;
6229                        }
6230                        other => {
6231                            return Err(self.err(alloc::format!(
6232                                "expected ',' or ')' in SET(...), got {other:?}"
6233                            )));
6234                        }
6235                    }
6236                }
6237                if variants.is_empty() {
6238                    return Err(self.err("SET(...) must declare at least one variant".into()));
6239                }
6240                inline_set_variants = Some(variants);
6241                ColumnTypeName::Text
6242            }
6243            _other => {
6244                // v7.17.0 Phase 1.4 — unknown ident → defer
6245                // resolution to the engine. Stored as Text in
6246                // ColumnTypeName + the original name carried as
6247                // `user_type_ref` so CREATE TABLE can look up
6248                // user-defined enum / domain types.
6249                user_type_ref = Some(ty_ident.clone());
6250                ColumnTypeName::Text
6251            }
6252        };
6253        // v7.17.0 Phase 4.4 — MySQL's `UNSIGNED` modifier sits
6254        // right after the type keyword. Pre-4.4 SPG consumed +
6255        // discarded the keyword, leaving a customer column
6256        // declared `id INT UNSIGNED NOT NULL` silently accepting
6257        // negative values — a Tier-A correctness drift where
6258        // application invariants (auto-increment-IDs never
6259        // negative) silently broke on cutover. Now: capture as
6260        // a column flag, persist on the schema, enforce at
6261        // INSERT / UPDATE time.
6262        let is_unsigned = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("unsigned"))
6263        {
6264            self.advance();
6265            true
6266        } else {
6267            false
6268        };
6269        // v7.14.0 — mysqldump emits `<type> CHARACTER SET <name>` and
6270        // `<type> COLLATE <name>` post-fixes on text columns. SPG
6271        // stores text as UTF-8 always so CHARACTER SET is still a
6272        // no-op. v7.17.0 Phase 2.5 — COLLATE no longer drops the
6273        // name: it gets classified into a `Collation` variant the
6274        // engine consults at WHERE-eval time. PG `default` /
6275        // `pg_catalog.default` / `C` / `POSIX` collations all
6276        // resolve to `Binary` (the prior behaviour); `_ci` /
6277        // `case_insensitive` / `nocase` shift to CaseInsensitive.
6278        // The schema-qualifier form (`pg_catalog.default`) lexes
6279        // as `Ident '.' Ident` — peek for the `.` and consume both
6280        // halves so it's treated as one collation name. PG's
6281        // `IDENT.IDENT` collation form (which can appear here) is
6282        // resolved by Collation::from_collation_name on the bare
6283        // identifier after the dot.
6284        let mut collation = Collation::Binary;
6285        loop {
6286            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("character"))
6287                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("set"))
6288            {
6289                self.advance(); // CHARACTER
6290                self.advance(); // SET
6291                if matches!(
6292                    self.peek(),
6293                    Token::Ident(_) | Token::QuotedIdent(_) | Token::String(_)
6294                ) {
6295                    self.advance();
6296                }
6297                continue;
6298            }
6299            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("collate")) {
6300                self.advance(); // COLLATE
6301                // Accept Ident / QuotedIdent / String AND the
6302                // keyword-tokenised `Default` (PG `pg_catalog.default`
6303                // and bare `DEFAULT` collation names — `default` is a
6304                // reserved word so the lexer hands back Token::Default
6305                // not Token::Ident).
6306                let read_collation_atom = |this: &mut Self| -> Option<alloc::string::String> {
6307                    match this.peek().clone() {
6308                        Token::Ident(s) | Token::QuotedIdent(s) | Token::String(s) => {
6309                            this.advance();
6310                            Some(s)
6311                        }
6312                        Token::Default => {
6313                            this.advance();
6314                            Some(alloc::string::String::from("default"))
6315                        }
6316                        _ => None,
6317                    }
6318                };
6319                let raw = if let Some(head) = read_collation_atom(self) {
6320                    // Schema-qualified PG form: `pg_catalog.default`.
6321                    if matches!(self.peek(), Token::Dot) {
6322                        self.advance();
6323                        let tail = read_collation_atom(self).unwrap_or_default();
6324                        alloc::format!("{head}.{tail}")
6325                    } else {
6326                        head
6327                    }
6328                } else {
6329                    alloc::string::String::new()
6330                };
6331                if !raw.is_empty() {
6332                    let parsed = Collation::from_collation_name(&raw);
6333                    // Last COLLATE clause wins, but `Binary` from a
6334                    // bare keyword like `default` should not
6335                    // silently downgrade a stronger one set earlier
6336                    // on the same column. v7.17 only ships one
6337                    // non-Binary variant so a simple OR is enough.
6338                    if parsed != Collation::Binary {
6339                        collation = parsed;
6340                    }
6341                }
6342                continue;
6343            }
6344            break;
6345        }
6346        // v7.10.10 — postfix `[]` widens TEXT → TEXT[]. PG accepts
6347        // `TYPE[]` after any base type; v7.10 only models TEXT[]
6348        // so we reject other base types here. mailrs uses TEXT[]
6349        // for labels / addresses / message-on-thread.
6350        if matches!(self.peek(), Token::LBracket) {
6351            self.advance();
6352            if !matches!(self.peek(), Token::RBracket) {
6353                return Err(self.err(alloc::format!(
6354                    "TEXT[] takes no dimension; got {:?}",
6355                    self.peek()
6356                )));
6357            }
6358            self.advance();
6359            // v7.11.13 — widened to INT[] and BIGINT[] in addition
6360            // to TEXT[]. Other base types (BOOL[], NUMERIC[], etc.)
6361            // still error here.
6362            ty = match ty {
6363                ColumnTypeName::Text => ColumnTypeName::TextArray,
6364                ColumnTypeName::Int => ColumnTypeName::IntArray,
6365                ColumnTypeName::BigInt => ColumnTypeName::BigIntArray,
6366                other => {
6367                    return Err(self.err(alloc::format!(
6368                        "v7.11 supports TEXT[] / INT[] / BIGINT[] only; got {other:?}[]"
6369                    )));
6370                }
6371            };
6372            // v7.17.0 Phase 3.P0-40 — second `[]` widens 1D → 2D
6373            // for INT/TEXT/BIGINT. Anything else is an error.
6374            if matches!(self.peek(), Token::LBracket) {
6375                self.advance();
6376                if !matches!(self.peek(), Token::RBracket) {
6377                    return Err(self.err(alloc::format!(
6378                        "TYPE[][] second dimension takes no size; got {:?}",
6379                        self.peek()
6380                    )));
6381                }
6382                self.advance();
6383                ty = match ty {
6384                    ColumnTypeName::IntArray => ColumnTypeName::IntArray2D,
6385                    ColumnTypeName::BigIntArray => ColumnTypeName::BigIntArray2D,
6386                    ColumnTypeName::TextArray => ColumnTypeName::TextArray2D,
6387                    other => {
6388                        return Err(self.err(alloc::format!(
6389                            "v7.17 2D arrays support INT[][] / BIGINT[][] / \
6390                             TEXT[][] only; got {other:?}"
6391                        )));
6392                    }
6393                };
6394            }
6395        }
6396        Ok((
6397            ty,
6398            implied_auto_increment,
6399            implied_not_null,
6400            user_type_ref,
6401            collation,
6402            is_unsigned,
6403            inline_enum_variants,
6404            inline_set_variants,
6405        ))
6406    }
6407
6408    fn parse_column_def(&mut self) -> Result<ColumnDef, ParseError> {
6409        // v7.20 — PG reserves the table-constraint keywords, so a
6410        // BARE `UNIQUE` / `PRIMARY` / … in column position is a
6411        // malformed constraint clause (e.g. `UNIQUE a` missing its
6412        // parens), not a column named "unique". Since v7.17's
6413        // unknown-type leniency (`user_type_ref`) such a clause
6414        // would otherwise parse as a column with a user-defined
6415        // type — silently accepting invalid DDL. Quoted
6416        // identifiers ("unique" / `unique`) remain valid names.
6417        if let Token::Ident(s) = self.peek()
6418            && [
6419                "unique",
6420                "primary",
6421                "foreign",
6422                "constraint",
6423                "check",
6424                "references",
6425                "exclude",
6426            ]
6427            .iter()
6428            .any(|kw| s.eq_ignore_ascii_case(kw))
6429        {
6430            return Err(self.err(alloc::format!(
6431                "unexpected reserved keyword '{s}' at start of column definition \
6432                 (malformed table constraint?)"
6433            )));
6434        }
6435        let name = self.expect_ident_like()?;
6436        let (
6437            ty,
6438            implied_auto_increment,
6439            implied_not_null,
6440            user_type_ref,
6441            collation,
6442            is_unsigned,
6443            inline_enum_variants,
6444            inline_set_variants,
6445        ) = self.parse_type_with_implied_flags()?;
6446        // Column constraints: `DEFAULT <expr>`, `NOT NULL`, and the
6447        // MySQL-flavoured `AUTO_INCREMENT` may appear in any order;
6448        // each at most once.
6449        let mut default: Option<Expr> = None;
6450        let mut nullable = !implied_not_null;
6451        let mut nullability_seen = implied_not_null;
6452        let mut auto_increment = implied_auto_increment;
6453        let mut is_primary_key = false;
6454        let mut is_unique = false;
6455        let mut check: Option<Expr> = None;
6456        let mut on_update_runtime: Option<Expr> = None;
6457        loop {
6458            // v7.22 (mailrs round-13 gap 3) — PG 18 catalogs
6459            // not-null constraints by name and pg_dump emits them
6460            // inline: `id bigint CONSTRAINT contacts_id_not_null1
6461            // NOT NULL`. Accept and discard the name; whatever
6462            // constraint follows is parsed by the arms below.
6463            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("constraint")) {
6464                self.advance();
6465                let _name = self.expect_ident_like()?;
6466                continue;
6467            }
6468            // v7.22 (round-13 T2) — inline `GENERATED { ALWAYS |
6469            // BY DEFAULT } AS IDENTITY [(seq options)]` (PG 10+;
6470            // the modern replacement for SERIAL in hand-written
6471            // schemas). Both flavours map onto the auto-increment
6472            // machinery — SPG's serial semantics ≈ BY DEFAULT;
6473            // ALWAYS's reject-explicit-values nuance is documented
6474            // leniency. Generated EXPRESSION columns
6475            // (`AS (expr) STORED`) are not supported: error loudly
6476            // instead of silently storing NULLs.
6477            if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("generated")) {
6478                self.advance();
6479                match self.peek().clone() {
6480                    Token::Ident(s) if s.eq_ignore_ascii_case("always") => {
6481                        self.advance();
6482                    }
6483                    // `BY` is a reserved keyword token (GROUP BY).
6484                    Token::By => {
6485                        self.advance();
6486                        if !matches!(self.peek(), Token::Default) {
6487                            return Err(self.err(alloc::format!(
6488                                "expected DEFAULT after GENERATED BY, got {:?}",
6489                                self.peek()
6490                            )));
6491                        }
6492                        self.advance();
6493                    }
6494                    other => {
6495                        return Err(self.err(alloc::format!(
6496                            "expected ALWAYS or BY DEFAULT after GENERATED, got {other:?}"
6497                        )));
6498                    }
6499                }
6500                if !matches!(self.peek(), Token::As) {
6501                    return Err(self.err(alloc::format!(
6502                        "expected AS after GENERATED ALWAYS/BY DEFAULT, got {:?}",
6503                        self.peek()
6504                    )));
6505                }
6506                self.advance();
6507                if matches!(self.peek(), Token::LParen) {
6508                    return Err(self.err(
6509                        "generated expression columns (GENERATED … AS (expr) STORED) \
6510                         are not supported"
6511                            .into(),
6512                    ));
6513                }
6514                self.expect_keyword_ident("identity")?;
6515                // Optional `(START WITH 1 INCREMENT BY 1 …)` —
6516                // consume the balanced parens and discard (SPG's
6517                // auto-increment is max+1-scan based).
6518                if matches!(self.peek(), Token::LParen) {
6519                    let mut depth = 0usize;
6520                    loop {
6521                        match self.advance() {
6522                            Token::LParen => depth += 1,
6523                            Token::RParen => {
6524                                depth -= 1;
6525                                if depth == 0 {
6526                                    break;
6527                                }
6528                            }
6529                            Token::Eof => {
6530                                return Err(self.err(
6531                                    "unterminated sequence-options parens after IDENTITY".into(),
6532                                ));
6533                            }
6534                            _ => {}
6535                        }
6536                    }
6537                }
6538                auto_increment = true;
6539                // PG identity columns are implicitly NOT NULL.
6540                nullable = false;
6541                continue;
6542            }
6543            // v7.17.0 Phase 2.1 — MySQL `ON UPDATE
6544            // CURRENT_TIMESTAMP[(N)]`. Only CURRENT_TIMESTAMP
6545            // is accepted today. The "ON" token is an Ident
6546            // (not reserved) — peek before consuming.
6547            if matches!(self.peek(), Token::On)
6548                && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s)) if s.eq_ignore_ascii_case("update"))
6549            {
6550                self.advance(); // ON
6551                self.advance(); // update
6552                // Accept CURRENT_TIMESTAMP / CURRENT_TIMESTAMP(N).
6553                let next = self.peek().clone();
6554                match next {
6555                    Token::Ident(s) | Token::QuotedIdent(s)
6556                        if s.eq_ignore_ascii_case("current_timestamp") =>
6557                    {
6558                        self.advance();
6559                        // Optional `(N)` precision.
6560                        if matches!(self.peek(), Token::LParen) {
6561                            self.advance();
6562                            if !matches!(self.peek(), Token::Integer(_)) {
6563                                return Err(self.err(alloc::format!(
6564                                    "expected integer precision inside CURRENT_TIMESTAMP(…), got {:?}",
6565                                    self.peek()
6566                                )));
6567                            }
6568                            self.advance();
6569                            if !matches!(self.peek(), Token::RParen) {
6570                                return Err(self.err(alloc::format!(
6571                                    "expected ')' after CURRENT_TIMESTAMP precision, got {:?}",
6572                                    self.peek()
6573                                )));
6574                            }
6575                            self.advance();
6576                        }
6577                        on_update_runtime = Some(Expr::FunctionCall {
6578                            name: "now".into(),
6579                            args: Vec::new(),
6580                        });
6581                        continue;
6582                    }
6583                    other => {
6584                        return Err(self.err(alloc::format!(
6585                            "v7.17 only supports ON UPDATE CURRENT_TIMESTAMP, got {other:?}"
6586                        )));
6587                    }
6588                }
6589            }
6590            if matches!(self.peek(), Token::Default) {
6591                if default.is_some() {
6592                    return Err(self.err("DEFAULT specified twice".into()));
6593                }
6594                self.advance();
6595                default = Some(self.parse_expr(0)?);
6596                continue;
6597            }
6598            if matches!(self.peek(), Token::Not) {
6599                if nullability_seen {
6600                    return Err(self.err("NOT NULL specified twice".into()));
6601                }
6602                self.advance();
6603                if !matches!(self.peek(), Token::Null) {
6604                    return Err(self.err(format!(
6605                        "expected NULL after NOT in column def, got {:?}",
6606                        self.peek()
6607                    )));
6608                }
6609                self.advance();
6610                nullable = false;
6611                nullability_seen = true;
6612                continue;
6613            }
6614            // v7.14.0 — MySQL accepts a bare `NULL` as an explicit
6615            // "this column is nullable" marker (the default in
6616            // standard SQL anyway). mysqldump emits it routinely
6617            // (`col TYPE NULL DEFAULT NULL` for nullable
6618            // timestamps etc). Accept + no-op.
6619            if matches!(self.peek(), Token::Null) {
6620                if nullability_seen && !nullable {
6621                    return Err(self.err("column declared NOT NULL then NULL — pick one".into()));
6622                }
6623                self.advance();
6624                nullable = true;
6625                nullability_seen = true;
6626                continue;
6627            }
6628            // `AUTO_INCREMENT` or its abbreviated form `AUTOINCREMENT`
6629            // arrives as a bare Ident. Match either, case-insensitive.
6630            if let Token::Ident(s) = self.peek()
6631                && (s.eq_ignore_ascii_case("auto_increment")
6632                    || s.eq_ignore_ascii_case("autoincrement"))
6633            {
6634                if auto_increment {
6635                    return Err(self.err("AUTO_INCREMENT specified twice".into()));
6636                }
6637                self.advance();
6638                auto_increment = true;
6639                continue;
6640            }
6641            // v7.9.13 — inline `PRIMARY KEY` column constraint
6642            // (mailrs F1). Implies `NOT NULL`. The engine creates
6643            // a BTree index for the PK column at CREATE TABLE time
6644            // so FK parent-side index lookups resolve.
6645            if let Token::Ident(s) = self.peek()
6646                && s.eq_ignore_ascii_case("primary")
6647            {
6648                if is_primary_key {
6649                    return Err(self.err("PRIMARY KEY specified twice".into()));
6650                }
6651                // Peek-ahead for the required `KEY` token.
6652                let next = self.tokens.get(self.pos + 1);
6653                let next_is_key = matches!(
6654                    next,
6655                    Some(Token::Ident(k)) if k.eq_ignore_ascii_case("key")
6656                );
6657                if !next_is_key {
6658                    return Err(self.err(format!(
6659                        "expected KEY after PRIMARY in column def, got {:?}",
6660                        next
6661                    )));
6662                }
6663                self.advance(); // PRIMARY
6664                self.advance(); // KEY
6665                is_primary_key = true;
6666                if nullability_seen && nullable {
6667                    return Err(self.err(
6668                        "column declared NULL but inline PRIMARY KEY implies NOT NULL".into(),
6669                    ));
6670                }
6671                nullable = false;
6672                nullability_seen = true;
6673                continue;
6674            }
6675            // v7.13.0 — inline `UNIQUE` column constraint
6676            // (mailrs round-5 G2). Fold into a single-column
6677            // table-level UNIQUE at CREATE TABLE post-process time.
6678            if let Token::Ident(s) = self.peek()
6679                && s.eq_ignore_ascii_case("unique")
6680            {
6681                if is_unique {
6682                    return Err(self.err("UNIQUE specified twice".into()));
6683                }
6684                self.advance();
6685                is_unique = true;
6686                continue;
6687            }
6688            // v7.13.0 — inline `CHECK (<expr>)` column constraint
6689            // (mailrs round-5 G3). PG semantics: column-level
6690            // CHECK is equivalent to a table-level CHECK. Multiple
6691            // inline CHECKs on the same column AND together.
6692            if let Token::Ident(s) = self.peek()
6693                && s.eq_ignore_ascii_case("check")
6694            {
6695                self.advance();
6696                if !matches!(self.peek(), Token::LParen) {
6697                    return Err(self.err(alloc::format!(
6698                        "expected '(' after CHECK in column def, got {:?}",
6699                        self.peek()
6700                    )));
6701                }
6702                self.advance();
6703                let pred = self.parse_expr(0)?;
6704                if !matches!(self.peek(), Token::RParen) {
6705                    return Err(self.err(alloc::format!(
6706                        "expected ')' to close CHECK predicate, got {:?}",
6707                        self.peek()
6708                    )));
6709                }
6710                self.advance();
6711                check = Some(match check.take() {
6712                    Some(prev) => Expr::Binary {
6713                        op: BinOp::And,
6714                        lhs: Box::new(prev),
6715                        rhs: Box::new(pred),
6716                    },
6717                    None => pred,
6718                });
6719                continue;
6720            }
6721            break;
6722        }
6723        Ok(ColumnDef {
6724            name,
6725            ty,
6726            nullable,
6727            default,
6728            auto_increment,
6729            is_primary_key,
6730            is_unique,
6731            check,
6732            user_type_ref,
6733            on_update_runtime,
6734            collation,
6735            is_unsigned,
6736            inline_enum_variants,
6737            inline_set_variants,
6738        })
6739    }
6740
6741    /// `NUMERIC` may appear without parameters, with one (precision
6742    /// only, scale=0), or with both. Returns `(precision, scale)` with
6743    /// 0 = unspecified for the bare form.
6744    fn parse_optional_numeric_params(&mut self) -> Result<(u8, u8), ParseError> {
6745        if !matches!(self.peek(), Token::LParen) {
6746            // Bare `NUMERIC` — PG treats this as "unlimited precision";
6747            // we surface it as precision=0 to mean "unconstrained" so
6748            // the engine doesn't need a separate variant.
6749            return Ok((0, 0));
6750        }
6751        self.advance();
6752        let precision = match self.advance() {
6753            Token::Integer(n) if (1..=38).contains(&n) => u8::try_from(n).expect("range-checked"),
6754            other => {
6755                return Err(ParseError {
6756                    message: format!(
6757                        "NUMERIC precision must be an integer in 1..=38, got {other:?}"
6758                    ),
6759                    token_pos: self.pos.saturating_sub(1),
6760                });
6761            }
6762        };
6763        let scale = if matches!(self.peek(), Token::Comma) {
6764            self.advance();
6765            match self.advance() {
6766                Token::Integer(n) if (0..=i64::from(precision)).contains(&n) => {
6767                    u8::try_from(n).expect("range-checked")
6768                }
6769                other => {
6770                    return Err(ParseError {
6771                        message: format!(
6772                            "NUMERIC scale must be a non-negative integer ≤ precision, got {other:?}"
6773                        ),
6774                        token_pos: self.pos.saturating_sub(1),
6775                    });
6776                }
6777            }
6778        } else {
6779            0
6780        };
6781        if !matches!(self.peek(), Token::RParen) {
6782            return Err(self.err(format!(
6783                "expected ')' to close NUMERIC params, got {:?}",
6784                self.peek()
6785            )));
6786        }
6787        self.advance();
6788        Ok((precision, scale))
6789    }
6790
6791    /// Parse `(N)` where `N` is a positive integer literal — used by the
6792    /// `VARCHAR`/`CHAR`/`VECTOR` column types. `label` is the type name
6793    /// for the error message.
6794    /// v6.0.1: parse the optional `USING <encoding>` clause that
6795    /// follows `VECTOR(N)` in a column definition. Missing clause
6796    /// → `VecEncoding::F32` (pre-v6 default). Unknown encoding
6797    /// ident → `ParseError` listing the encodings recognised today.
6798    fn parse_optional_vector_encoding(&mut self) -> Result<VecEncoding, ParseError> {
6799        if !matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("using")) {
6800            return Ok(VecEncoding::F32);
6801        }
6802        // v7.13.2 — mailrs round-6 S6: `USING` after a vector type
6803        // overlaps with `ALTER COLUMN TYPE … USING <expr>`. Only
6804        // consume the token when the very next token is a known
6805        // vector-encoding keyword (SQ8 / HALF). Otherwise leave
6806        // `USING` for the caller — it's the rewrite-expression form.
6807        let n1 = self.tokens.get(self.pos + 1);
6808        let next_is_encoding = matches!(
6809            n1,
6810            Some(Token::Ident(s))
6811                if s.eq_ignore_ascii_case("sq8") || s.eq_ignore_ascii_case("half")
6812        );
6813        if !next_is_encoding {
6814            return Ok(VecEncoding::F32);
6815        }
6816        self.advance();
6817        let enc_ident = match self.advance() {
6818            Token::Ident(s) => s,
6819            other => {
6820                return Err(self.err(format!(
6821                    "expected vector encoding after USING, got {other:?}"
6822                )));
6823            }
6824        };
6825        match enc_ident.to_ascii_lowercase().as_str() {
6826            "sq8" => Ok(VecEncoding::Sq8),
6827            // v6.0.3: `HALF` (pgvector convention) selects IEEE-754
6828            // binary16 per-element storage.
6829            "half" => Ok(VecEncoding::F16),
6830            other => Err(self.err(format!(
6831                "unknown vector encoding {other:?}; supported: SQ8, HALF"
6832            ))),
6833        }
6834    }
6835
6836    /// v7.17.0 Phase 4.3 — peek at the MySQL display-width
6837    /// without consuming it. Returns `Some(N)` when the next
6838    /// tokens are `( <int> )`; None otherwise. Used by the
6839    /// TINYINT classifier to decide whether to map to Bool or
6840    /// SmallInt.
6841    fn peek_optional_paren_size_value(&self) -> Option<i64> {
6842        if !matches!(self.peek(), Token::LParen) {
6843            return None;
6844        }
6845        let next = self.tokens.get(self.pos + 1)?;
6846        let n = match next {
6847            Token::Integer(n) => *n,
6848            _ => return None,
6849        };
6850        if !matches!(self.tokens.get(self.pos + 2), Some(Token::RParen)) {
6851            return None;
6852        }
6853        Some(n)
6854    }
6855
6856    /// v7.14.0 — consume an optional MySQL display-width
6857    /// parenthesised number after an integer type, returning
6858    /// nothing. `TINYINT(1)` etc.
6859    fn consume_optional_paren_size(&mut self) {
6860        if !matches!(self.peek(), Token::LParen) {
6861            return;
6862        }
6863        self.advance();
6864        // Skip until matching RParen (allow nested or any tokens).
6865        let mut depth = 1usize;
6866        while depth > 0 {
6867            match self.peek() {
6868                Token::LParen => depth += 1,
6869                Token::RParen => depth -= 1,
6870                Token::Eof => return,
6871                _ => {}
6872            }
6873            self.advance();
6874        }
6875    }
6876
6877    fn parse_paren_size(&mut self, label: &str) -> Result<u32, ParseError> {
6878        if !matches!(self.peek(), Token::LParen) {
6879            return Err(self.err(format!("{label} type requires (N), got {:?}", self.peek())));
6880        }
6881        self.advance();
6882        let n = match self.advance() {
6883            Token::Integer(n) if n > 0 => u32::try_from(n).map_err(|_| ParseError {
6884                message: format!("{label} size too large: {n}"),
6885                token_pos: self.pos.saturating_sub(1),
6886            })?,
6887            other => {
6888                return Err(ParseError {
6889                    message: format!("expected positive integer {label} size, got {other:?}"),
6890                    token_pos: self.pos.saturating_sub(1),
6891                });
6892            }
6893        };
6894        if !matches!(self.peek(), Token::RParen) {
6895            return Err(self.err(format!(
6896                "expected ')' after {label} size, got {:?}",
6897                self.peek()
6898            )));
6899        }
6900        self.advance();
6901        Ok(n)
6902    }
6903
6904    fn parse_insert_stmt(&mut self) -> Result<Statement, ParseError> {
6905        debug_assert!(matches!(self.peek(), Token::Insert));
6906        self.advance();
6907        if !matches!(self.peek(), Token::Into) {
6908            return Err(self.err(format!("expected INTO after INSERT, got {:?}", self.peek())));
6909        }
6910        self.advance();
6911        let table = self.expect_ident_like()?;
6912        // Optional column list — `INSERT INTO t (a, b) VALUES ...`.
6913        let columns = if matches!(self.peek(), Token::LParen) {
6914            self.advance();
6915            let mut names = Vec::new();
6916            loop {
6917                names.push(self.expect_ident_like()?);
6918                match self.peek() {
6919                    Token::Comma => {
6920                        self.advance();
6921                    }
6922                    Token::RParen => {
6923                        self.advance();
6924                        break;
6925                    }
6926                    other => {
6927                        return Err(self.err(format!(
6928                            "expected ',' or ')' in INSERT column list, got {other:?}"
6929                        )));
6930                    }
6931                }
6932            }
6933            Some(names)
6934        } else {
6935            None
6936        };
6937        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6938        // round-5 G4). Dispatch on VALUES vs SELECT.
6939        if matches!(self.peek(), Token::Select) {
6940            let select_stmt = match self.parse_select_stmt()? {
6941                Statement::Select(s) => s,
6942                other => {
6943                    return Err(self.err(alloc::format!(
6944                        "expected SELECT after INSERT INTO ... target, got {other:?}"
6945                    )));
6946                }
6947            };
6948            let on_conflict = self.parse_optional_on_conflict()?;
6949            let returning = self.parse_optional_returning()?;
6950            return Ok(Statement::Insert(InsertStatement {
6951                table,
6952                columns,
6953                rows: Vec::new(),
6954                select_source: Some(Box::new(select_stmt)),
6955                on_conflict,
6956                returning,
6957            }));
6958        }
6959        if !matches!(self.peek(), Token::Values) {
6960            return Err(self.err(format!(
6961                "expected VALUES or SELECT after table name, got {:?}",
6962                self.peek()
6963            )));
6964        }
6965        self.advance();
6966        if !matches!(self.peek(), Token::LParen) {
6967            return Err(self.err(format!("expected '(' after VALUES, got {:?}", self.peek())));
6968        }
6969        let mut rows = Vec::new();
6970        loop {
6971            // Each iteration consumes one `(expr, expr, …)` tuple.
6972            if !matches!(self.peek(), Token::LParen) {
6973                return Err(self.err(format!(
6974                    "expected '(' for next VALUES tuple, got {:?}",
6975                    self.peek()
6976                )));
6977            }
6978            self.advance();
6979            let mut tuple = Vec::new();
6980            loop {
6981                tuple.push(self.parse_expr(0)?);
6982                match self.peek() {
6983                    Token::Comma => {
6984                        self.advance();
6985                    }
6986                    Token::RParen => {
6987                        self.advance();
6988                        break;
6989                    }
6990                    other => {
6991                        return Err(self.err(format!(
6992                            "expected ',' or ')' in VALUES tuple, got {other:?}"
6993                        )));
6994                    }
6995                }
6996            }
6997            if tuple.is_empty() {
6998                return Err(self.err("INSERT VALUES tuple requires at least one value".into()));
6999            }
7000            rows.push(tuple);
7001            // Continue with comma-separated tuples.
7002            if matches!(self.peek(), Token::Comma) {
7003                self.advance();
7004            } else {
7005                break;
7006            }
7007        }
7008        let on_conflict = self.parse_optional_on_conflict()?;
7009        let returning = self.parse_optional_returning()?;
7010        Ok(Statement::Insert(InsertStatement {
7011            table,
7012            columns,
7013            rows,
7014            select_source: None,
7015            on_conflict,
7016            returning,
7017        }))
7018    }
7019
7020    /// v7.9.7 — parse the optional `ON CONFLICT (cols) DO …`
7021    /// clause sitting between the INSERT body and the trailing
7022    /// RETURNING. All keywords come in as bare idents; `ON` is
7023    /// a reserved Token though.
7024    fn parse_optional_on_conflict(
7025        &mut self,
7026    ) -> Result<Option<crate::ast::OnConflictClause>, ParseError> {
7027        if !matches!(self.peek(), Token::On) {
7028            return Ok(None);
7029        }
7030        // Peek further: we want exactly "ON CONFLICT ...". If the
7031        // next ident isn't "conflict", let some other parser handle.
7032        let next_is_conflict = matches!(
7033            self.tokens.get(self.pos + 1),
7034            Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("conflict")
7035        );
7036        if !next_is_conflict {
7037            return Ok(None);
7038        }
7039        self.advance(); // ON
7040        self.advance(); // CONFLICT
7041        // Optional `(col [, col]*)` target list.
7042        let mut target_columns: Vec<String> = Vec::new();
7043        if matches!(self.peek(), Token::LParen) {
7044            self.advance();
7045            loop {
7046                target_columns.push(self.expect_ident_like()?);
7047                match self.peek() {
7048                    Token::Comma => {
7049                        self.advance();
7050                    }
7051                    Token::RParen => {
7052                        self.advance();
7053                        break;
7054                    }
7055                    other => {
7056                        return Err(self.err(alloc::format!(
7057                            "expected ',' or ')' in ON CONFLICT target list, got {other:?}"
7058                        )));
7059                    }
7060                }
7061            }
7062        }
7063        // Required `DO`.
7064        match self.advance() {
7065            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("do") => {}
7066            other => {
7067                return Err(self.err(alloc::format!(
7068                    "expected DO after ON CONFLICT [(…)], got {other:?}"
7069                )));
7070            }
7071        }
7072        // Action: NOTHING | UPDATE SET …
7073        let action = match self.advance() {
7074            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("nothing") => {
7075                crate::ast::OnConflictAction::Nothing
7076            }
7077            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("update") => {
7078                self.parse_on_conflict_update_action()?
7079            }
7080            other => {
7081                return Err(self.err(alloc::format!(
7082                    "expected NOTHING or UPDATE after ON CONFLICT DO, got {other:?}"
7083                )));
7084            }
7085        };
7086        Ok(Some(crate::ast::OnConflictClause {
7087            target_columns,
7088            action,
7089        }))
7090    }
7091
7092    /// v7.9.7 — tail of `ON CONFLICT … DO UPDATE`: parse
7093    /// `SET col = expr [, …] [WHERE cond]`. Caller already
7094    /// consumed `UPDATE`.
7095    fn parse_on_conflict_update_action(
7096        &mut self,
7097    ) -> Result<crate::ast::OnConflictAction, ParseError> {
7098        // `SET`
7099        match self.advance() {
7100            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("set") => {}
7101            other => {
7102                return Err(self.err(alloc::format!(
7103                    "expected SET after ON CONFLICT DO UPDATE, got {other:?}"
7104                )));
7105            }
7106        }
7107        let mut assignments: Vec<(String, Expr)> = Vec::new();
7108        loop {
7109            let col = self.expect_ident_like()?;
7110            if !matches!(self.peek(), Token::Eq) {
7111                return Err(self.err(alloc::format!(
7112                    "expected `=` after column in ON CONFLICT DO UPDATE SET, got {:?}",
7113                    self.peek()
7114                )));
7115            }
7116            self.advance();
7117            let value = self.parse_expr(0)?;
7118            assignments.push((col, value));
7119            if matches!(self.peek(), Token::Comma) {
7120                self.advance();
7121                continue;
7122            }
7123            break;
7124        }
7125        let where_ = if matches!(self.peek(), Token::Where) {
7126            self.advance();
7127            Some(self.parse_expr(0)?)
7128        } else {
7129            None
7130        };
7131        Ok(crate::ast::OnConflictAction::Update {
7132            assignments,
7133            where_,
7134        })
7135    }
7136
7137    fn parse_select_list(&mut self) -> Result<Vec<SelectItem>, ParseError> {
7138        let mut items = Vec::new();
7139        loop {
7140            items.push(self.parse_select_item()?);
7141            if matches!(self.peek(), Token::Comma) {
7142                self.advance();
7143            } else {
7144                break;
7145            }
7146        }
7147        Ok(items)
7148    }
7149
7150    fn parse_select_item(&mut self) -> Result<SelectItem, ParseError> {
7151        if matches!(self.peek(), Token::Star) {
7152            self.advance();
7153            return Ok(SelectItem::Wildcard);
7154        }
7155        let expr = self.parse_expr(0)?;
7156        let alias = self.parse_optional_alias();
7157        Ok(SelectItem::Expr { expr, alias })
7158    }
7159
7160    fn parse_table_ref(&mut self) -> Result<TableRef, ParseError> {
7161        // v7.17.0 Phase 3.P0-41 — `LATERAL ( SELECT … )` derived
7162        // table. Detect at the head so it claims precedence over
7163        // every other table-ref shape (unnest / generate_series /
7164        // bare ident); the lateral subquery itself follows the
7165        // regular SELECT grammar.
7166        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("lateral"))
7167            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7168        {
7169            self.advance(); // LATERAL
7170            self.advance(); // (
7171            // Parse the inner SELECT.
7172            let inner = match self.parse_one_statement()? {
7173                Statement::Select(s) => s,
7174                other => {
7175                    return Err(self.err(alloc::format!(
7176                        "expected SELECT inside LATERAL ( … ), got {other:?}"
7177                    )));
7178                }
7179            };
7180            if !matches!(self.peek(), Token::RParen) {
7181                return Err(self.err(alloc::format!(
7182                    "expected ')' after LATERAL subquery, got {:?}",
7183                    self.peek()
7184                )));
7185            }
7186            self.advance();
7187            let alias_ident = self.parse_optional_alias();
7188            let name = alias_ident.clone().unwrap_or_else(|| "lateral".to_string());
7189            return Ok(TableRef {
7190                name,
7191                alias: alias_ident,
7192                as_of_segment: None,
7193                unnest_expr: None,
7194                unnest_column_aliases: Vec::new(),
7195                generate_series_args: None,
7196                lateral_subquery: Some(Box::new(inner)),
7197            });
7198        }
7199        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>` set-returning
7200        // source. Detect at the head before the bare-ident fallback;
7201        // unnest is not a reserved token.
7202        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("unnest"))
7203            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7204        {
7205            self.advance(); // unnest
7206            self.advance(); // (
7207            let expr = self.parse_expr(0)?;
7208            if !matches!(self.peek(), Token::RParen) {
7209                return Err(self.err(alloc::format!(
7210                    "expected ')' after unnest() argument, got {:?}",
7211                    self.peek()
7212                )));
7213            }
7214            self.advance();
7215            let (alias_ident, unnest_column_aliases) = self.parse_optional_alias_with_columns();
7216            let name = alias_ident.clone().unwrap_or_else(|| "unnest".to_string());
7217            return Ok(TableRef {
7218                name,
7219                alias: alias_ident,
7220                as_of_segment: None,
7221                unnest_expr: Some(Box::new(expr)),
7222                unnest_column_aliases,
7223                generate_series_args: None,
7224                lateral_subquery: None,
7225            });
7226        }
7227        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7228        // [, step])` set-returning source. Same shape as unnest:
7229        // detect at the head, parse the comma-separated arg list,
7230        // dispatch downstream through the engine's set-returning
7231        // path. Supports integer triplets (mailrs's `WITH row_no AS
7232        // (SELECT * FROM generate_series(1, N))` pattern) and
7233        // TIMESTAMP + INTERVAL triplets (the Tier-A audit's
7234        // date-range iteration pattern, which pre-3.10 had no
7235        // direct equivalent in SPG).
7236        if matches!(self.peek(), Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("generate_series"))
7237            && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen))
7238        {
7239            self.advance(); // generate_series
7240            self.advance(); // (
7241            let mut args: Vec<Expr> = Vec::new();
7242            loop {
7243                args.push(self.parse_expr(0)?);
7244                if matches!(self.peek(), Token::Comma) {
7245                    self.advance();
7246                    continue;
7247                }
7248                break;
7249            }
7250            if !matches!(self.peek(), Token::RParen) {
7251                return Err(self.err(alloc::format!(
7252                    "expected ')' after generate_series() arguments, got {:?}",
7253                    self.peek()
7254                )));
7255            }
7256            self.advance();
7257            if args.len() < 2 || args.len() > 3 {
7258                return Err(self.err(alloc::format!(
7259                    "generate_series() expects 2 or 3 arguments (start, stop [, step]); got {}",
7260                    args.len()
7261                )));
7262            }
7263            let (alias_ident, _column_aliases) = self.parse_optional_alias_with_columns();
7264            let name = alias_ident
7265                .clone()
7266                .unwrap_or_else(|| "generate_series".to_string());
7267            return Ok(TableRef {
7268                name,
7269                alias: alias_ident,
7270                as_of_segment: None,
7271                unnest_expr: None,
7272                unnest_column_aliases: Vec::new(),
7273                generate_series_args: Some(args),
7274                lateral_subquery: None,
7275            });
7276        }
7277        // v7.16.2 — preserve information_schema / pg_catalog
7278        // qualifiers (mailrs round-10 A.3). The generic
7279        // `expect_ident_like` strip silently drops the schema;
7280        // we want the engine to recognise these PG meta tables
7281        // and synthesise rows from the live catalog. Produce a
7282        // synthetic name (`__spg_info_columns` etc.) so the
7283        // engine's SELECT-side router can dispatch without
7284        // clashing with any user-defined `columns` table.
7285        let name = if let Some(synth) = self.try_peek_meta_qualified() {
7286            synth
7287        } else if let Some(synth) = self.try_peek_meta_bare() {
7288            synth
7289        } else {
7290            self.expect_ident_like()?
7291        };
7292        // v6.10.2 — optional `AS OF SEGMENT '<id>'` cold-tier
7293        // time-travel clause. Parse BEFORE the alias so the
7294        // alias can still ride at the tail (`tbl AS OF SEGMENT
7295        // '5' alias`). `AS` is a reserved keyword token, while
7296        // `OF` and `SEGMENT` are bare idents.
7297        let as_of_segment = if matches!(self.peek(), Token::As)
7298            && matches!(self.tokens.get(self.pos + 1), Some(Token::Ident(s) | Token::QuotedIdent(s)) if s.eq_ignore_ascii_case("of"))
7299        {
7300            self.advance(); // AS
7301            self.advance(); // OF
7302            let kw = match self.peek().clone() {
7303                Token::Ident(s) | Token::QuotedIdent(s) => s,
7304                other => {
7305                    return Err(self.err(format!("expected SEGMENT after AS OF, got {other:?}")));
7306                }
7307            };
7308            if !kw.eq_ignore_ascii_case("segment") {
7309                return Err(self.err(format!(
7310                    "expected SEGMENT after AS OF, got {kw:?}; v6.10.2 supports SEGMENT only"
7311                )));
7312            }
7313            self.advance();
7314            // Segment id literal — accept either a string or
7315            // integer for operator ergonomics.
7316            let id = match self.advance() {
7317                Token::String(s) => s
7318                    .parse::<u32>()
7319                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7320                Token::Integer(n) => u32::try_from(n)
7321                    .map_err(|e| self.err(format!("AS OF SEGMENT id parse: {e}")))?,
7322                other => {
7323                    return Err(self.err(format!(
7324                        "expected segment id literal after AS OF SEGMENT, got {other:?}"
7325                    )));
7326                }
7327            };
7328            Some(id)
7329        } else {
7330            None
7331        };
7332        let alias = self.parse_optional_alias();
7333        Ok(TableRef {
7334            name,
7335            alias,
7336            as_of_segment,
7337            unnest_expr: None,
7338            unnest_column_aliases: Vec::new(),
7339            generate_series_args: None,
7340            lateral_subquery: None,
7341        })
7342    }
7343
7344    /// v7.13.2 — mailrs round-6 S5. Like `parse_optional_alias`
7345    /// but also accepts `AS alias(col [, col, …])` — the
7346    /// PG-standard table-function column-list form. The column
7347    /// list is only honoured when paired with `UNNEST(...)` in
7348    /// the parent; other call sites currently discard it.
7349    fn parse_optional_alias_with_columns(&mut self) -> (Option<String>, Vec<String>) {
7350        let alias = self.parse_optional_alias();
7351        if alias.is_none() {
7352            return (None, Vec::new());
7353        }
7354        let mut cols: Vec<String> = Vec::new();
7355        if matches!(self.peek(), Token::LParen) {
7356            self.advance();
7357            while let Token::Ident(s) | Token::QuotedIdent(s) = self.peek().clone() {
7358                self.advance();
7359                cols.push(s);
7360                if matches!(self.peek(), Token::Comma) {
7361                    self.advance();
7362                    continue;
7363                }
7364                break;
7365            }
7366            if matches!(self.peek(), Token::RParen) {
7367                self.advance();
7368            }
7369        }
7370        (alias, cols)
7371    }
7372
7373    /// FROM-clause: a primary table reference plus zero-or-more joined
7374    /// peers expressed via either `, <table>` (cross-product, no ON) or
7375    /// `[INNER|LEFT [OUTER]|CROSS] JOIN <table> [ON expr]`. v1.10 keeps
7376    /// the join list flat (left-associative nested-loop semantics).
7377    fn parse_from_clause(&mut self) -> Result<FromClause, ParseError> {
7378        let primary = self.parse_table_ref()?;
7379        let mut joins = Vec::new();
7380        loop {
7381            // `, <table>` — cross-product with no ON.
7382            if matches!(self.peek(), Token::Comma) {
7383                self.advance();
7384                let table = self.parse_table_ref()?;
7385                joins.push(FromJoin {
7386                    kind: JoinKind::Cross,
7387                    table,
7388                    on: None,
7389                });
7390                continue;
7391            }
7392            // Explicit JOIN syntax. Accept INNER JOIN, LEFT [OUTER] JOIN,
7393            // CROSS JOIN, and bare JOIN (defaults to INNER).
7394            let kind =
7395                match self.peek() {
7396                    Token::Inner => {
7397                        self.advance();
7398                        if !matches!(self.peek(), Token::Join) {
7399                            return Err(self
7400                                .err(format!("expected JOIN after INNER, got {:?}", self.peek())));
7401                        }
7402                        self.advance();
7403                        JoinKind::Inner
7404                    }
7405                    Token::Left => {
7406                        self.advance();
7407                        if matches!(self.peek(), Token::Outer) {
7408                            self.advance();
7409                        }
7410                        if !matches!(self.peek(), Token::Join) {
7411                            return Err(self.err(format!(
7412                                "expected JOIN after LEFT [OUTER], got {:?}",
7413                                self.peek()
7414                            )));
7415                        }
7416                        self.advance();
7417                        JoinKind::Left
7418                    }
7419                    Token::Cross => {
7420                        self.advance();
7421                        if !matches!(self.peek(), Token::Join) {
7422                            return Err(self
7423                                .err(format!("expected JOIN after CROSS, got {:?}", self.peek())));
7424                        }
7425                        self.advance();
7426                        JoinKind::Cross
7427                    }
7428                    Token::Join => {
7429                        self.advance();
7430                        JoinKind::Inner
7431                    }
7432                    _ => break,
7433                };
7434            let table = self.parse_table_ref()?;
7435            let on = if matches!(self.peek(), Token::On) {
7436                self.advance();
7437                Some(self.parse_expr(0)?)
7438            } else if kind == JoinKind::Cross {
7439                None
7440            } else {
7441                return Err(self.err(format!(
7442                    "expected ON after {:?} JOIN, got {:?}",
7443                    kind,
7444                    self.peek()
7445                )));
7446            };
7447            joins.push(FromJoin { kind, table, on });
7448        }
7449        Ok(FromClause { primary, joins })
7450    }
7451
7452    /// Optional alias after an expression or table:
7453    /// `AS <ident>` is unambiguous; a bare `<ident>` directly after is also
7454    /// accepted (PG-style implicit alias). Returns `None` if the next token
7455    /// is not alias-shaped (e.g. comma, FROM, WHERE, semicolon, EOF, operator).
7456    fn parse_optional_alias(&mut self) -> Option<String> {
7457        if matches!(self.peek(), Token::As) {
7458            self.advance();
7459            // After AS, the next token MUST be an identifier-like — if not,
7460            // we still return None and let the caller surface the error on the
7461            // next expectation. v0.2 keeps the alias path forgiving; the
7462            // corpus tests don't exercise the malformed case.
7463            if let Token::Ident(_) | Token::QuotedIdent(_) = self.peek() {
7464                return self.expect_ident_like().ok();
7465            }
7466            return None;
7467        }
7468        // v7.17.0 Phase 1.3 — implicit alias (no `AS`). PG's
7469        // grammar reserves a long list of follow-keywords from the
7470        // alias slot. SPG's bareword approximation: skip a small
7471        // set of idents that would otherwise be swallowed as the
7472        // table alias and break trailing clauses like CREATE
7473        // MATERIALIZED VIEW … WITH [NO] DATA or future ON
7474        // CONFLICT WHERE shapes.
7475        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
7476            if is_alias_stopword(s) {
7477                return None;
7478            }
7479            return self.expect_ident_like().ok();
7480        }
7481        None
7482    }
7483
7484    /// Pratt loop. `min_prec` is the minimum binary-op precedence we'll accept.
7485    fn parse_expr(&mut self, min_prec: u8) -> Result<Expr, ParseError> {
7486        let mut lhs = self.parse_unary()?;
7487        while let Some((op, prec)) = binop_from(self.peek()) {
7488            if prec < min_prec {
7489                break;
7490            }
7491            self.advance();
7492            // v7.10.12 — `x <op> ANY(arr)` / `x <op> ALL(arr)`.
7493            // ANY is a bare ident; ALL is a reserved Token. Both
7494            // require an immediate `(` to disambiguate from
7495            // identifier columns named `any` / `all`.
7496            let any_kind = match self.peek() {
7497                Token::All if matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) => {
7498                    Some(false)
7499                }
7500                Token::Ident(s) | Token::QuotedIdent(s)
7501                    if (s.eq_ignore_ascii_case("any") || s.eq_ignore_ascii_case("all"))
7502                        && matches!(self.tokens.get(self.pos + 1), Some(Token::LParen)) =>
7503                {
7504                    Some(s.eq_ignore_ascii_case("any"))
7505                }
7506                _ => None,
7507            };
7508            if let Some(is_any) = any_kind {
7509                self.advance(); // ident
7510                self.advance(); // (
7511                let arr = self.parse_expr(0)?;
7512                if !matches!(self.peek(), Token::RParen) {
7513                    return Err(self.err(alloc::format!(
7514                        "expected ')' after ANY/ALL argument, got {:?}",
7515                        self.peek()
7516                    )));
7517                }
7518                self.advance();
7519                lhs = Expr::AnyAll {
7520                    expr: Box::new(lhs),
7521                    op,
7522                    array: Box::new(arr),
7523                    is_any,
7524                };
7525                continue;
7526            }
7527            let rhs = self.parse_expr(prec + 1)?;
7528            lhs = Expr::Binary {
7529                lhs: Box::new(lhs),
7530                op,
7531                rhs: Box::new(rhs),
7532            };
7533        }
7534        Ok(lhs)
7535    }
7536
7537    fn parse_unary(&mut self) -> Result<Expr, ParseError> {
7538        match self.peek() {
7539            Token::Not => {
7540                self.advance();
7541                // NOT sits between AND (2) and comparisons (4) — bind everything
7542                // ≥3, which leaves AND/OR outside.
7543                let e = self.parse_expr(3)?;
7544                Ok(Expr::Unary {
7545                    op: UnOp::Not,
7546                    expr: Box::new(e),
7547                })
7548            }
7549            Token::Minus => {
7550                self.advance();
7551                // Unary minus binds tighter than `*`/`/` (now at prec 7 after
7552                // `<->` slotted into 5 and arithmetic shifted up).
7553                let e = self.parse_expr(8)?;
7554                Ok(Expr::Unary {
7555                    op: UnOp::Neg,
7556                    expr: Box::new(e),
7557                })
7558            }
7559            Token::Tilde => {
7560                self.advance();
7561                // Bitwise NOT binds like unary minus.
7562                let e = self.parse_expr(8)?;
7563                Ok(Expr::Unary {
7564                    op: UnOp::BitNot,
7565                    expr: Box::new(e),
7566                })
7567            }
7568            _ => self.parse_atom(),
7569        }
7570    }
7571
7572    fn parse_atom(&mut self) -> Result<Expr, ParseError> {
7573        let tok_pos = self.pos;
7574        match self.advance() {
7575            Token::Integer(n) => Ok(Expr::Literal(Literal::Integer(n))),
7576            Token::Float(x) => Ok(Expr::Literal(Literal::Float(x))),
7577            Token::String(s) => Ok(Expr::Literal(Literal::String(s))),
7578            Token::True => Ok(Expr::Literal(Literal::Bool(true))),
7579            Token::False => Ok(Expr::Literal(Literal::Bool(false))),
7580            Token::Null => Ok(Expr::Literal(Literal::Null)),
7581            // v6.1.1 — `$N` placeholder. The actual Value lookup
7582            // happens in the engine eval path against the prepared-
7583            // statement bind buffer.
7584            Token::Placeholder(n) => Ok(Expr::Placeholder(n)),
7585            Token::LParen => {
7586                // v4.10: `(SELECT ...)` in expression position is a
7587                // scalar subquery; otherwise it's a parenthesised
7588                // expression. Peek for SELECT keyword to dispatch.
7589                if matches!(self.peek(), Token::Select) {
7590                    let inner = self.parse_select_stmt()?;
7591                    match self.advance() {
7592                        Token::RParen => {
7593                            let Statement::Select(s) = inner else {
7594                                unreachable!("parse_select_stmt returns Select")
7595                            };
7596                            Ok(Expr::ScalarSubquery(Box::new(s)))
7597                        }
7598                        other => Err(ParseError {
7599                            message: format!("expected ')' after scalar subquery, got {other:?}"),
7600                            token_pos: self.pos.saturating_sub(1),
7601                        }),
7602                    }
7603                } else {
7604                    let e = self.parse_expr(0)?;
7605                    match self.advance() {
7606                        Token::RParen => Ok(e),
7607                        other => Err(ParseError {
7608                            message: format!("expected ')', got {other:?}"),
7609                            token_pos: self.pos.saturating_sub(1),
7610                        }),
7611                    }
7612                }
7613            }
7614            Token::LBracket => self.parse_vector_literal_body(),
7615            Token::Extract => self.parse_extract_atom(),
7616            Token::Interval => self.parse_interval_atom(),
7617            // `LEFT` is a reserved-keyword token because the
7618            // grammar dedicates an arm for `LEFT [OUTER] JOIN`.
7619            // When `left` is followed by `(` we're in expression
7620            // position calling the PG `left(string, n)` function;
7621            // rebuild the AST as a regular function call so the
7622            // engine's apply_function dispatch picks it up.
7623            Token::Left if matches!(self.peek(), Token::LParen) => {
7624                self.advance(); // (
7625                let mut args = Vec::new();
7626                if !matches!(self.peek(), Token::RParen) {
7627                    loop {
7628                        args.push(self.parse_expr(0)?);
7629                        match self.peek() {
7630                            Token::Comma => {
7631                                self.advance();
7632                            }
7633                            Token::RParen => break,
7634                            other => {
7635                                return Err(self.err(alloc::format!(
7636                                    "expected ',' or ')' in left() args, got {other:?}"
7637                                )));
7638                            }
7639                        }
7640                    }
7641                }
7642                self.advance(); // )
7643                Ok(Expr::FunctionCall {
7644                    name: "left".into(),
7645                    args,
7646                })
7647            }
7648            // v4.10: EXISTS / NOT EXISTS. EXISTS isn't a reserved
7649            // token; we match on the bare ident. NOT is a token
7650            // (consumed in the comparison rung), but `EXISTS (...)`
7651            // at the top of an expression starts here.
7652            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("exists") => {
7653                self.parse_exists_atom(false)
7654            }
7655            // v7.13.0 — `CASE [<operand>] WHEN <cond> THEN <val>
7656            // [WHEN ...] [ELSE <val>] END` (mailrs round-5 G9).
7657            // CASE is a bare ident; we dispatch on lowercase match.
7658            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("case") => {
7659                self.parse_case_atom()
7660            }
7661            // v7.10.10 — `ARRAY[expr, expr, …]` constructor. ARRAY
7662            // is not a reserved token; we match by case-insensitive
7663            // ident. The opening `[` must follow immediately.
7664            Token::Ident(s) | Token::QuotedIdent(s)
7665                if s.eq_ignore_ascii_case("array") && matches!(self.peek(), Token::LBracket) =>
7666            {
7667                self.advance(); // consume `[`
7668                let mut items: Vec<Expr> = Vec::new();
7669                if !matches!(self.peek(), Token::RBracket) {
7670                    loop {
7671                        items.push(self.parse_expr(0)?);
7672                        match self.peek() {
7673                            Token::Comma => {
7674                                self.advance();
7675                            }
7676                            Token::RBracket => break,
7677                            other => {
7678                                return Err(self.err(alloc::format!(
7679                                    "expected ',' or ']' in ARRAY literal, got {other:?}"
7680                                )));
7681                            }
7682                        }
7683                    }
7684                }
7685                self.advance(); // consume `]`
7686                Ok(Expr::Array(items))
7687            }
7688            // v7.17.0 Phase 2.2 — MySQL `MATCH(col, ...) AGAINST
7689            // ('term' [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE])`.
7690            // We special-case before the generic ident dispatch so
7691            // the AGAINST clause never reaches the function-call
7692            // loop (which would mis-read `(cols) AGAINST` as a
7693            // call with no trailing modifier). The shape is
7694            // rewritten to a Boolean OR over per-column
7695            // `to_tsvector('simple', col) @@ plainto_tsquery('simple',
7696            // term)` so the existing FTS evaluator handles
7697            // semantics — the fulltext-GIN built at CREATE TABLE
7698            // time is currently a "real index that survives dump
7699            // round-trip"; the planner hook that actually uses
7700            // it for posting-list intersection lands in a later
7701            // sub-phase (Phase 2.2b) without touching this surface.
7702            Token::Ident(s) | Token::QuotedIdent(s)
7703                if s.eq_ignore_ascii_case("match") && matches!(self.peek(), Token::LParen) =>
7704            {
7705                self.parse_match_against_atom()
7706            }
7707            Token::Ident(s) | Token::QuotedIdent(s) => self.finish_ident_atom(s),
7708            other => Err(ParseError {
7709                message: format!("unexpected token {other:?} in expression"),
7710                token_pos: tok_pos,
7711            }),
7712        }
7713        // After parsing the atom, fold any postfix `::vector` casts.
7714        .and_then(|atom| self.finish_postfix_casts(atom))
7715    }
7716
7717    /// Postfix operators on an atom: `::TYPE` cast and `IS [NOT] NULL`.
7718    /// Both bind tighter than any binary op.
7719    fn finish_postfix_casts(&mut self, mut expr: Expr) -> Result<Expr, ParseError> {
7720        loop {
7721            if matches!(self.peek(), Token::DoubleColon) {
7722                self.advance();
7723                // v7.9.25 / v7.9.26 — broaden the postfix `::` cast
7724                // target set to include INTERVAL (reserved Token),
7725                // TIMESTAMPTZ, and PG catalog regtype / regclass.
7726                // mailrs follow-up H3a + H3b.
7727                let target = match self.advance() {
7728                    Token::Ident(s) => match s.to_ascii_lowercase().as_str() {
7729                        "int" | "integer" | "int4" => {
7730                            if matches!(self.peek(), Token::LBracket)
7731                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7732                            {
7733                                self.advance();
7734                                self.advance();
7735                                CastTarget::IntArray
7736                            } else {
7737                                CastTarget::Int
7738                            }
7739                        }
7740                        "bigint" | "int8" => {
7741                            if matches!(self.peek(), Token::LBracket)
7742                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7743                            {
7744                                self.advance();
7745                                self.advance();
7746                                CastTarget::BigIntArray
7747                            } else {
7748                                CastTarget::BigInt
7749                            }
7750                        }
7751                        "float" | "double" | "real" => CastTarget::Float,
7752                        "text" => {
7753                            // v7.10.11 — `::TEXT[]` widens to TextArray.
7754                            if matches!(self.peek(), Token::LBracket)
7755                                && matches!(self.tokens.get(self.pos + 1), Some(Token::RBracket))
7756                            {
7757                                self.advance();
7758                                self.advance();
7759                                CastTarget::TextArray
7760                            } else {
7761                                CastTarget::Text
7762                            }
7763                        }
7764                        "bool" | "boolean" => CastTarget::Bool,
7765                        "vector" => CastTarget::Vector,
7766                        "date" => CastTarget::Date,
7767                        "timestamp" | "datetime" => CastTarget::Timestamp,
7768                        "timestamptz" => CastTarget::Timestamptz,
7769                        "interval" => CastTarget::Interval,
7770                        "json" => CastTarget::Json,
7771                        "jsonb" => CastTarget::Jsonb,
7772                        "regtype" => CastTarget::RegType,
7773                        "regclass" => CastTarget::RegClass,
7774                        // v7.12.0 — `::tsvector` / `::tsquery`.
7775                        // Engine decodes the LHS text via the PG
7776                        // external form parser.
7777                        "tsvector" => CastTarget::TsVector,
7778                        "tsquery" => CastTarget::TsQuery,
7779                        // v7.17.0 — `::uuid`. Engine decodes the LHS
7780                        // text via `spg_storage::parse_uuid_str`.
7781                        "uuid" => CastTarget::Uuid,
7782                        // v7.18 — `::bytea`. Engine decodes the LHS
7783                        // text via the PG hex form (`'\xdeadbeef'`)
7784                        // or escape form (`'\\x05\\x00'`). Closes
7785                        // mailrs D-pre #3 reverse-acceptance gap.
7786                        "bytea" => CastTarget::Bytea,
7787                        // v7.17.0 Phase 3.P0-47 — `::inet` / `::cidr` /
7788                        // `::macaddr`. SPG stores these as Text (Phase 7);
7789                        // the cast is a no-op passthrough so containment
7790                        // and overlap operators can read the textual form.
7791                        "inet" | "cidr" | "macaddr" => CastTarget::Text,
7792                        other => {
7793                            return Err(ParseError {
7794                                message: format!("unsupported cast target `::{other}`"),
7795                                token_pos: self.pos.saturating_sub(1),
7796                            });
7797                        }
7798                    },
7799                    Token::Interval => CastTarget::Interval,
7800                    other => {
7801                        return Err(ParseError {
7802                            message: format!("expected type ident after `::`, got {other:?}"),
7803                            token_pos: self.pos.saturating_sub(1),
7804                        });
7805                    }
7806                };
7807                expr = Expr::Cast {
7808                    expr: Box::new(expr),
7809                    target,
7810                };
7811                continue;
7812            }
7813            if matches!(self.peek(), Token::Is) {
7814                self.advance();
7815                let negated = if matches!(self.peek(), Token::Not) {
7816                    self.advance();
7817                    true
7818                } else {
7819                    false
7820                };
7821                // v7.9.27b — `IS [NOT] DISTINCT FROM <rhs>`.
7822                // mailrs pg_dump.
7823                if matches!(self.peek(), Token::Distinct) {
7824                    self.advance();
7825                    if !matches!(self.peek(), Token::From) {
7826                        return Err(self.err(format!(
7827                            "expected FROM after IS{} DISTINCT, got {:?}",
7828                            if negated { " NOT" } else { "" },
7829                            self.peek()
7830                        )));
7831                    }
7832                    self.advance();
7833                    // Right-hand side: parse at the same precedence
7834                    // tier as comparison so `x IS DISTINCT FROM a + b`
7835                    // groups as `x IS DISTINCT FROM (a + b)`.
7836                    let rhs = self.parse_expr(20)?;
7837                    let op = if negated {
7838                        BinOp::IsNotDistinctFrom
7839                    } else {
7840                        BinOp::IsDistinctFrom
7841                    };
7842                    expr = Expr::Binary {
7843                        op,
7844                        lhs: Box::new(expr),
7845                        rhs: Box::new(rhs),
7846                    };
7847                    continue;
7848                }
7849                if !matches!(self.peek(), Token::Null) {
7850                    return Err(self.err(format!(
7851                        "expected NULL or DISTINCT after IS{}, got {:?}",
7852                        if negated { " NOT" } else { "" },
7853                        self.peek()
7854                    )));
7855                }
7856                self.advance();
7857                expr = Expr::IsNull {
7858                    expr: Box::new(expr),
7859                    negated,
7860                };
7861                continue;
7862            }
7863            // `x [NOT] BETWEEN a AND b`, `x [NOT] IN (...)`, `x [NOT] LIKE p`.
7864            // Look one token ahead so a stray `NOT` not followed by any of
7865            // these flows through to the early return below untouched.
7866            let negated = if matches!(self.peek(), Token::Not) {
7867                let next = self.tokens.get(self.pos + 1);
7868                matches!(next, Some(Token::Between | Token::In | Token::Like))
7869            } else {
7870                false
7871            };
7872            if negated {
7873                self.advance();
7874            }
7875            if matches!(self.peek(), Token::Between) {
7876                expr = self.parse_between_tail(expr, negated)?;
7877                continue;
7878            }
7879            if matches!(self.peek(), Token::In) {
7880                expr = self.parse_in_tail(expr, negated)?;
7881                continue;
7882            }
7883            if matches!(self.peek(), Token::Like) {
7884                self.advance();
7885                // Pattern at the same precedence as other comparison RHSes —
7886                // 5 leaves AND/OR alone so `a LIKE 'x%' AND b` parses right.
7887                let pattern = self.parse_expr(5)?;
7888                expr = Expr::Like {
7889                    expr: Box::new(expr),
7890                    pattern: Box::new(pattern),
7891                    negated,
7892                };
7893                continue;
7894            }
7895            // v7.10.12 — `arr[i]` subscript. PG 1-based; engine
7896            // returns NULL for out-of-range. Multiple subscripts
7897            // chain: `a[i][j]` parses left-to-right.
7898            if matches!(self.peek(), Token::LBracket) {
7899                self.advance();
7900                let index = self.parse_expr(0)?;
7901                if !matches!(self.peek(), Token::RBracket) {
7902                    return Err(self.err(alloc::format!(
7903                        "expected ']' after array index, got {:?}",
7904                        self.peek()
7905                    )));
7906                }
7907                self.advance();
7908                expr = Expr::ArraySubscript {
7909                    target: Box::new(expr),
7910                    index: Box::new(index),
7911                };
7912                continue;
7913            }
7914            return Ok(expr);
7915        }
7916    }
7917
7918    /// `x BETWEEN low AND high`  →  `(x >= low) AND (x <= high)`, wrapped in
7919    /// `NOT` when `negated`. Bounds parse at precedence 5 so the trailing
7920    /// `AND` is not swallowed.
7921    fn parse_between_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
7922        self.advance(); // BETWEEN
7923        let low = self.parse_expr(5)?;
7924        if !matches!(self.peek(), Token::And) {
7925            return Err(self.err(format!(
7926                "expected AND after BETWEEN low bound, got {:?}",
7927                self.peek()
7928            )));
7929        }
7930        self.advance();
7931        let high = self.parse_expr(5)?;
7932        let target = Box::new(expr);
7933        let combined = Expr::Binary {
7934            lhs: Box::new(Expr::Binary {
7935                lhs: target.clone(),
7936                op: BinOp::GtEq,
7937                rhs: Box::new(low),
7938            }),
7939            op: BinOp::And,
7940            rhs: Box::new(Expr::Binary {
7941                lhs: target,
7942                op: BinOp::LtEq,
7943                rhs: Box::new(high),
7944            }),
7945        };
7946        Ok(maybe_not(combined, negated))
7947    }
7948
7949    /// `x IN (a, b, c)`  →  chained OR of equalities. Empty list collapses
7950    /// to FALSE (TRUE under NOT IN), matching standard SQL semantics.
7951    /// v4.11: parse `WITH name AS (SELECT ...) [, ...] SELECT ...`.
7952    /// Caller already consumed the leading `WITH` ident.
7953    fn parse_with_cte_then_select(&mut self) -> Result<Statement, ParseError> {
7954        // v4.22: WITH RECURSIVE — optional keyword right after WITH.
7955        // Comes through as an identifier; consume it if present and
7956        // mark every CTE in the clause as recursive (PG semantics —
7957        // the flag is per-WITH, not per-CTE).
7958        let mut recursive = false;
7959        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
7960            && s.eq_ignore_ascii_case("recursive")
7961        {
7962            self.advance();
7963            recursive = true;
7964        }
7965        let mut ctes = Vec::new();
7966        loop {
7967            let name = self.expect_ident_like()?;
7968            // v4.22: optional column-name list — `WITH t(a,b,c) AS ...`.
7969            // PG uses these to rename the body's output columns; we
7970            // do the same below by overriding `columns[i].name`.
7971            let column_overrides: Vec<String> = if matches!(self.peek(), Token::LParen) {
7972                self.advance();
7973                let mut names = Vec::new();
7974                loop {
7975                    names.push(self.expect_ident_like()?);
7976                    if matches!(self.peek(), Token::Comma) {
7977                        self.advance();
7978                        continue;
7979                    }
7980                    break;
7981                }
7982                if !matches!(self.peek(), Token::RParen) {
7983                    return Err(self.err(format!(
7984                        "expected ')' to close CTE column list, got {:?}",
7985                        self.peek()
7986                    )));
7987                }
7988                self.advance();
7989                names
7990            } else {
7991                Vec::new()
7992            };
7993            // AS is a reserved Token::As (used by SELECT-item / FROM
7994            // aliasing) — handle it specially rather than as a bare
7995            // ident.
7996            if !matches!(self.peek(), Token::As) {
7997                return Err(self.err(format!(
7998                    "expected AS after CTE name {name:?}, got {:?}",
7999                    self.peek()
8000                )));
8001            }
8002            self.advance();
8003            if !matches!(self.peek(), Token::LParen) {
8004                return Err(self.err(format!(
8005                    "expected '(' after AS in WITH clause, got {:?}",
8006                    self.peek()
8007                )));
8008            }
8009            self.advance();
8010            if !matches!(self.peek(), Token::Select) {
8011                return Err(self.err(format!("WITH body must be a SELECT, got {:?}", self.peek())));
8012            }
8013            let inner = self.parse_select_stmt()?;
8014            if !matches!(self.peek(), Token::RParen) {
8015                return Err(self.err(format!(
8016                    "expected ')' after CTE body, got {:?}",
8017                    self.peek()
8018                )));
8019            }
8020            self.advance();
8021            let Statement::Select(body) = inner else {
8022                unreachable!("parse_select_stmt returns Select")
8023            };
8024            ctes.push(crate::ast::Cte {
8025                name,
8026                body,
8027                recursive,
8028                column_overrides,
8029            });
8030            if matches!(self.peek(), Token::Comma) {
8031                self.advance();
8032                continue;
8033            }
8034            break;
8035        }
8036        // The body SELECT follows. Must start with SELECT.
8037        if !matches!(self.peek(), Token::Select) {
8038            return Err(self.err(format!(
8039                "expected SELECT after WITH clause, got {:?}",
8040                self.peek()
8041            )));
8042        }
8043        let body_stmt = self.parse_select_stmt()?;
8044        let Statement::Select(mut body) = body_stmt else {
8045            unreachable!()
8046        };
8047        body.ctes = ctes;
8048        Ok(Statement::Select(body))
8049    }
8050
8051    /// v4.10: parse `EXISTS (SELECT ...)`. Caller (`parse_atom`)
8052    /// already consumed the leading `EXISTS` ident via
8053    /// `self.advance()`.
8054    /// v7.13.0 — parse the rest of a `CASE … END` expression after
8055    /// the leading `CASE` ident has been consumed (mailrs round-5
8056    /// G9). Supports both the searched form
8057    /// (`CASE WHEN cond THEN val …`) and the simple form
8058    /// (`CASE operand WHEN val THEN val …`).
8059    fn parse_case_atom(&mut self) -> Result<Expr, ParseError> {
8060        // Disambiguate searched vs simple form: if the next token
8061        // is `WHEN`, we're in the searched form. Otherwise the
8062        // intervening expression is the operand.
8063        let operand = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("when")) {
8064            None
8065        } else {
8066            Some(Box::new(self.parse_expr(0)?))
8067        };
8068        let mut branches: Vec<(Expr, Expr)> = Vec::new();
8069        loop {
8070            match self.peek() {
8071                Token::Ident(s) if s.eq_ignore_ascii_case("when") => {
8072                    self.advance();
8073                    let cond = self.parse_expr(0)?;
8074                    match self.peek() {
8075                        Token::Ident(t) if t.eq_ignore_ascii_case("then") => {
8076                            self.advance();
8077                        }
8078                        other => {
8079                            return Err(self.err(alloc::format!(
8080                                "expected THEN after CASE WHEN <expr>, got {other:?}"
8081                            )));
8082                        }
8083                    }
8084                    let value = self.parse_expr(0)?;
8085                    branches.push((cond, value));
8086                }
8087                _ => break,
8088            }
8089        }
8090        if branches.is_empty() {
8091            return Err(self.err("CASE requires at least one WHEN … THEN … branch".into()));
8092        }
8093        let else_branch = if matches!(self.peek(), Token::Ident(s) if s.eq_ignore_ascii_case("else"))
8094        {
8095            self.advance();
8096            Some(Box::new(self.parse_expr(0)?))
8097        } else {
8098            None
8099        };
8100        match self.peek() {
8101            Token::Ident(s) if s.eq_ignore_ascii_case("end") => {
8102                self.advance();
8103            }
8104            other => {
8105                return Err(self.err(alloc::format!(
8106                    "expected END to close CASE expression, got {other:?}"
8107                )));
8108            }
8109        }
8110        Ok(Expr::Case {
8111            operand,
8112            branches,
8113            else_branch,
8114        })
8115    }
8116
8117    fn parse_exists_atom(&mut self, negated: bool) -> Result<Expr, ParseError> {
8118        if !matches!(self.peek(), Token::LParen) {
8119            return Err(self.err(format!("expected '(' after EXISTS, got {:?}", self.peek())));
8120        }
8121        self.advance();
8122        let inner = self.parse_select_stmt()?;
8123        if !matches!(self.peek(), Token::RParen) {
8124            return Err(self.err(format!(
8125                "expected ')' after EXISTS-subquery, got {:?}",
8126                self.peek()
8127            )));
8128        }
8129        self.advance();
8130        let Statement::Select(s) = inner else {
8131            unreachable!("parse_select_stmt returns Select")
8132        };
8133        Ok(Expr::Exists {
8134            subquery: Box::new(s),
8135            negated,
8136        })
8137    }
8138
8139    fn parse_in_tail(&mut self, expr: Expr, negated: bool) -> Result<Expr, ParseError> {
8140        self.advance(); // IN
8141        if !matches!(self.peek(), Token::LParen) {
8142            return Err(self.err(format!("expected '(' after IN, got {:?}", self.peek())));
8143        }
8144        self.advance();
8145        // v4.10: `IN (SELECT ...)` — subquery branch.
8146        if matches!(self.peek(), Token::Select) {
8147            let inner = self.parse_select_stmt()?;
8148            if !matches!(self.peek(), Token::RParen) {
8149                return Err(self.err(format!(
8150                    "expected ')' after IN-subquery, got {:?}",
8151                    self.peek()
8152                )));
8153            }
8154            self.advance();
8155            let Statement::Select(s) = inner else {
8156                unreachable!("parse_select_stmt always returns Statement::Select")
8157            };
8158            return Ok(Expr::InSubquery {
8159                expr: Box::new(expr),
8160                subquery: Box::new(s),
8161                negated,
8162            });
8163        }
8164        let mut elements = Vec::new();
8165        if !matches!(self.peek(), Token::RParen) {
8166            loop {
8167                elements.push(self.parse_expr(0)?);
8168                match self.peek() {
8169                    Token::Comma => {
8170                        self.advance();
8171                    }
8172                    Token::RParen => break,
8173                    other => {
8174                        return Err(
8175                            self.err(format!("expected ',' or ')' in IN list, got {other:?}"))
8176                        );
8177                    }
8178                }
8179            }
8180        }
8181        self.advance(); // ')'
8182        let target = Box::new(expr);
8183        let combined = if elements.is_empty() {
8184            Expr::Literal(Literal::Bool(false))
8185        } else {
8186            let mut iter = elements.into_iter();
8187            let first = iter.next().unwrap();
8188            let mut acc = Expr::Binary {
8189                lhs: target.clone(),
8190                op: BinOp::Eq,
8191                rhs: Box::new(first),
8192            };
8193            for elt in iter {
8194                acc = Expr::Binary {
8195                    lhs: Box::new(acc),
8196                    op: BinOp::Or,
8197                    rhs: Box::new(Expr::Binary {
8198                        lhs: target.clone(),
8199                        op: BinOp::Eq,
8200                        rhs: Box::new(elt),
8201                    }),
8202                };
8203            }
8204            acc
8205        };
8206        Ok(maybe_not(combined, negated))
8207    }
8208
8209    /// Parse a pgvector array literal `[ x1, x2, ... ]`. The opening `[` is
8210    /// already consumed by the caller. Elements must be numeric literals
8211    /// (with optional unary `-`); any compound expression is rejected at
8212    /// parse time so the runtime never needs to evaluate inside a vector.
8213    /// `EXTRACT(<field> FROM <source>)`. The dispatching `parse_atom`
8214    /// has already consumed the `EXTRACT` token before calling us —
8215    /// we pick up at the opening `(`.
8216    /// v7.17.0 Phase 2.2 — MySQL `MATCH(col [, col ...]) AGAINST
8217    /// (expr [IN BOOLEAN MODE | IN NATURAL LANGUAGE MODE
8218    /// [WITH QUERY EXPANSION]])`. Rewritten in-place to a
8219    /// per-column OR-fold of
8220    /// `to_tsvector('simple', col) @@ plainto_tsquery('simple',
8221    /// term)` so the existing FTS evaluator handles semantics.
8222    ///
8223    /// The mode modifier is accepted-and-ignored at v7.17 — all
8224    /// modes map to the same `plainto_tsquery` rewrite. Boolean-
8225    /// mode operators (`+foo -bar`) would need their own parser
8226    /// (Phase 2.2c); customers who hit them today already get a
8227    /// correct lexeme-match against the bare term, only without
8228    /// the +/- precedence the customer asked for.
8229    fn parse_match_against_atom(&mut self) -> Result<Expr, ParseError> {
8230        // Already at `MATCH`-consumed position; the dispatcher
8231        // confirmed the next token is `(`.
8232        if !matches!(self.peek(), Token::LParen) {
8233            return Err(self.err(alloc::format!(
8234                "expected '(' after MATCH, got {:?}",
8235                self.peek()
8236            )));
8237        }
8238        self.advance();
8239        let mut cols: Vec<Expr> = Vec::new();
8240        loop {
8241            cols.push(self.parse_expr(0)?);
8242            match self.peek() {
8243                Token::Comma => {
8244                    self.advance();
8245                }
8246                Token::RParen => break,
8247                other => {
8248                    return Err(self.err(alloc::format!(
8249                        "expected ',' or ')' in MATCH column list, got {other:?}"
8250                    )));
8251                }
8252            }
8253        }
8254        self.advance(); // ')'
8255        // Expect AGAINST.
8256        match self.peek() {
8257            Token::Ident(s) | Token::QuotedIdent(s) if s.eq_ignore_ascii_case("against") => {
8258                self.advance();
8259            }
8260            other => {
8261                return Err(self.err(alloc::format!(
8262                    "expected AGAINST after MATCH column list, got {other:?}"
8263                )));
8264            }
8265        }
8266        if !matches!(self.peek(), Token::LParen) {
8267            return Err(self.err(alloc::format!(
8268                "expected '(' after AGAINST, got {:?}",
8269                self.peek()
8270            )));
8271        }
8272        self.advance();
8273        // Read AGAINST's argument as a single primary token —
8274        // string literal, placeholder, or column-ref ident. We
8275        // can't call `parse_expr` / `parse_unary` here because
8276        // the postfix chain inside `parse_atom` would greedily
8277        // fold a trailing `IN BOOLEAN MODE` as `expr IN (...)`
8278        // and fail at "expected '(' after IN". Customers always
8279        // write a literal or bound parameter in AGAINST, so this
8280        // restriction is non-blocking; the error path explains
8281        // the limit if a more complex expression shows up.
8282        let term = match self.advance() {
8283            Token::String(s) => Expr::Literal(crate::ast::Literal::String(s)),
8284            Token::Placeholder(n) => Expr::Placeholder(n),
8285            Token::Ident(s) | Token::QuotedIdent(s) => Expr::Column(crate::ast::ColumnName {
8286                qualifier: None,
8287                name: s,
8288            }),
8289            other => {
8290                return Err(self.err(alloc::format!(
8291                    "MATCH ... AGAINST(<term>) expects a string literal, \
8292                     bound parameter, or column ref, got {other:?}"
8293                )));
8294            }
8295        };
8296        // Optional mode tail — accept-and-ignore at v7.17:
8297        //   IN NATURAL LANGUAGE MODE [WITH QUERY EXPANSION]
8298        //   IN BOOLEAN MODE
8299        //   WITH QUERY EXPANSION
8300        loop {
8301            match self.peek() {
8302                // IN lexes as a reserved Token::In, not an ident,
8303                // so it gets its own arm.
8304                Token::In => {
8305                    self.advance();
8306                }
8307                Token::Ident(s) | Token::QuotedIdent(s)
8308                    if s.eq_ignore_ascii_case("natural")
8309                        || s.eq_ignore_ascii_case("language")
8310                        || s.eq_ignore_ascii_case("boolean")
8311                        || s.eq_ignore_ascii_case("mode")
8312                        || s.eq_ignore_ascii_case("with")
8313                        || s.eq_ignore_ascii_case("query")
8314                        || s.eq_ignore_ascii_case("expansion") =>
8315                {
8316                    self.advance();
8317                }
8318                _ => break,
8319            }
8320        }
8321        if !matches!(self.peek(), Token::RParen) {
8322            return Err(self.err(alloc::format!(
8323                "expected ')' to close AGAINST, got {:?}",
8324                self.peek()
8325            )));
8326        }
8327        self.advance();
8328        // Build per-column `to_tsvector('simple', col) @@
8329        // plainto_tsquery('simple', term)` and OR-fold.
8330        let simple_lit = || Expr::Literal(crate::ast::Literal::String(String::from("simple")));
8331        let plainto = Expr::FunctionCall {
8332            name: String::from("plainto_tsquery"),
8333            args: alloc::vec![simple_lit(), term.clone()],
8334        };
8335        let mut folded: Option<Expr> = None;
8336        for col in cols {
8337            let to_tsv = Expr::FunctionCall {
8338                name: String::from("to_tsvector"),
8339                args: alloc::vec![simple_lit(), col],
8340            };
8341            let leaf = Expr::Binary {
8342                lhs: Box::new(to_tsv),
8343                op: crate::ast::BinOp::TsMatch,
8344                rhs: Box::new(plainto.clone()),
8345            };
8346            folded = Some(match folded {
8347                None => leaf,
8348                Some(prev) => Expr::Binary {
8349                    lhs: Box::new(prev),
8350                    op: crate::ast::BinOp::Or,
8351                    rhs: Box::new(leaf),
8352                },
8353            });
8354        }
8355        match folded {
8356            Some(e) => Ok(e),
8357            None => Err(self.err(String::from(
8358                "MATCH(...) AGAINST(...) requires at least one column",
8359            ))),
8360        }
8361    }
8362
8363    fn parse_extract_atom(&mut self) -> Result<Expr, ParseError> {
8364        if !matches!(self.peek(), Token::LParen) {
8365            return Err(self.err(format!("expected '(' after EXTRACT, got {:?}", self.peek())));
8366        }
8367        self.advance();
8368        let field_name = self.expect_ident_like()?;
8369        let field = match field_name.to_ascii_lowercase().as_str() {
8370            "year" => ExtractField::Year,
8371            "month" => ExtractField::Month,
8372            "day" => ExtractField::Day,
8373            "hour" => ExtractField::Hour,
8374            "minute" => ExtractField::Minute,
8375            "second" => ExtractField::Second,
8376            "microsecond" | "microseconds" => ExtractField::Microsecond,
8377            "epoch" => ExtractField::Epoch,
8378            other => {
8379                return Err(self.err(format!(
8380                    "unknown EXTRACT field {other:?}; \
8381                     supported: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, MICROSECOND, EPOCH"
8382                )));
8383            }
8384        };
8385        if !matches!(self.peek(), Token::From) {
8386            return Err(self.err(format!(
8387                "expected FROM after EXTRACT field, got {:?}",
8388                self.peek()
8389            )));
8390        }
8391        self.advance();
8392        let source = self.parse_expr(0)?;
8393        if !matches!(self.peek(), Token::RParen) {
8394            return Err(self.err(format!(
8395                "expected ')' to close EXTRACT, got {:?}",
8396                self.peek()
8397            )));
8398        }
8399        self.advance();
8400        Ok(Expr::Extract {
8401            field,
8402            source: Box::new(source),
8403        })
8404    }
8405
8406    /// `INTERVAL '<n> <unit> [<n> <unit> ...]'` — the `INTERVAL` keyword
8407    /// is already consumed; we expect a single string literal next and
8408    /// resolve it into `Literal::Interval` at parse time so the engine
8409    /// never has to re-tokenise inside the string.
8410    fn parse_interval_atom(&mut self) -> Result<Expr, ParseError> {
8411        let tok = self.advance();
8412        let Token::String(text) = tok else {
8413            return Err(self.err(format!(
8414                "expected string literal after INTERVAL, got {tok:?}"
8415            )));
8416        };
8417        let (months, micros) = parse_interval_text(&text).ok_or_else(|| ParseError {
8418            message: format!(
8419                "cannot parse INTERVAL {text:?}; \
8420                     expected `<n> <unit> [<n> <unit> ...]` with units \
8421                     microsecond[s], millisecond[s], second[s], minute[s], \
8422                     hour[s], day[s], week[s], month[s], year[s]"
8423            ),
8424            token_pos: self.pos.saturating_sub(1),
8425        })?;
8426        Ok(Expr::Literal(Literal::Interval {
8427            months,
8428            micros,
8429            text,
8430        }))
8431    }
8432
8433    fn parse_vector_literal_body(&mut self) -> Result<Expr, ParseError> {
8434        let mut elems = Vec::new();
8435        if matches!(self.peek(), Token::RBracket) {
8436            self.advance();
8437            return Ok(Expr::Literal(Literal::Vector(elems)));
8438        }
8439        loop {
8440            let e = self.parse_expr(0)?;
8441            let x = extract_numeric_literal(&e).ok_or_else(|| ParseError {
8442                message: format!("vector element must be a numeric literal, got {e:?}"),
8443                token_pos: self.pos,
8444            })?;
8445            elems.push(x);
8446            match self.peek() {
8447                Token::Comma => {
8448                    self.advance();
8449                }
8450                Token::RBracket => {
8451                    self.advance();
8452                    break;
8453                }
8454                other => {
8455                    return Err(self.err(format!("expected ',' or ']' in vector, got {other:?}")));
8456                }
8457            }
8458        }
8459        Ok(Expr::Literal(Literal::Vector(elems)))
8460    }
8461
8462    /// Atom that started with an identifier: could be `t.col`, `col`, or
8463    /// `func(arg, ...)`. Detect each shape by looking at the next token.
8464    /// v4.12: parse `(PARTITION BY expr, ... ORDER BY expr [DESC]
8465    /// [, ...])`. Caller has already consumed `OVER`. Either clause
8466    /// is optional; an empty `()` is also legal (PG semantics).
8467    /// v6.4.2 — consume an optional `IGNORE NULLS` / `RESPECT NULLS`
8468    /// modifier between `name(args)` and `OVER (...)`. Default is
8469    /// `Respect`. Unrecognised idents leave the stream unchanged.
8470    fn parse_null_treatment_modifier(&mut self) -> NullTreatment {
8471        let Token::Ident(s) = self.peek().clone() else {
8472            return NullTreatment::Respect;
8473        };
8474        let is_ignore = s.eq_ignore_ascii_case("ignore");
8475        let is_respect = s.eq_ignore_ascii_case("respect");
8476        if !is_ignore && !is_respect {
8477            return NullTreatment::Respect;
8478        }
8479        // Lookahead for NULLS — only consume both tokens together.
8480        // pos+1 must hold a "nulls" ident.
8481        if self.pos + 1 < self.tokens.len()
8482            && let Token::Ident(s2) = &self.tokens[self.pos + 1]
8483            && s2.eq_ignore_ascii_case("nulls")
8484        {
8485            self.advance();
8486            self.advance();
8487            return if is_ignore {
8488                NullTreatment::Ignore
8489            } else {
8490                NullTreatment::Respect
8491            };
8492        }
8493        NullTreatment::Respect
8494    }
8495
8496    /// No frame clause is supported.
8497    #[allow(clippy::type_complexity)] // (partitions, ordered-keys-with-desc) is the natural shape
8498    fn parse_over_clause(
8499        &mut self,
8500    ) -> Result<
8501        (
8502            Vec<Expr>,
8503            Vec<(Expr, bool, Option<bool>)>,
8504            Option<WindowFrame>,
8505        ),
8506        ParseError,
8507    > {
8508        if !matches!(self.peek(), Token::LParen) {
8509            return Err(self.err(format!("expected '(' after OVER, got {:?}", self.peek())));
8510        }
8511        self.advance();
8512        let mut partition_by = Vec::new();
8513        let mut order_by = Vec::new();
8514        // PARTITION BY ?
8515        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8516            && s.eq_ignore_ascii_case("partition")
8517        {
8518            self.advance();
8519            if !matches!(self.peek(), Token::By) {
8520                return Err(self.err(format!(
8521                    "expected BY after PARTITION, got {:?}",
8522                    self.peek()
8523                )));
8524            }
8525            self.advance();
8526            loop {
8527                partition_by.push(self.parse_expr(0)?);
8528                if matches!(self.peek(), Token::Comma) {
8529                    self.advance();
8530                    continue;
8531                }
8532                break;
8533            }
8534        }
8535        // ORDER BY ?
8536        if matches!(self.peek(), Token::Order) {
8537            self.advance();
8538            if !matches!(self.peek(), Token::By) {
8539                return Err(self.err(format!("expected BY after ORDER, got {:?}", self.peek())));
8540            }
8541            self.advance();
8542            loop {
8543                let e = self.parse_expr(0)?;
8544                let desc = if matches!(self.peek(), Token::Desc) {
8545                    self.advance();
8546                    true
8547                } else if matches!(self.peek(), Token::Asc) {
8548                    self.advance();
8549                    false
8550                } else {
8551                    false
8552                };
8553                // v7.24.1 — NULLS FIRST/LAST inside OVER (…).
8554                let nulls_first = self.parse_optional_nulls_placement()?;
8555                order_by.push((e, desc, nulls_first));
8556                if matches!(self.peek(), Token::Comma) {
8557                    self.advance();
8558                    continue;
8559                }
8560                break;
8561            }
8562        }
8563        // v4.20: optional explicit frame, `ROWS ...` / `RANGE ...`.
8564        // Both keywords come through the lexer as identifiers; match
8565        // case-insensitively.
8566        let mut frame: Option<WindowFrame> = None;
8567        if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek() {
8568            let kind = if s.eq_ignore_ascii_case("rows") {
8569                Some(FrameKind::Rows)
8570            } else if s.eq_ignore_ascii_case("range") {
8571                Some(FrameKind::Range)
8572            } else {
8573                None
8574            };
8575            if let Some(kind) = kind {
8576                self.advance();
8577                frame = Some(self.parse_frame_tail(kind)?);
8578            }
8579        }
8580        if !matches!(self.peek(), Token::RParen) {
8581            return Err(self.err(format!(
8582                "expected ')' to close OVER clause, got {:?}",
8583                self.peek()
8584            )));
8585        }
8586        self.advance();
8587        Ok((partition_by, order_by, frame))
8588    }
8589
8590    /// v4.20: parse the tail of an explicit frame, given the `ROWS`
8591    /// or `RANGE` keyword was just consumed. Accepts both
8592    /// `BETWEEN <bound> AND <bound>` and the single-bound shorthand
8593    /// (`ROWS UNBOUNDED PRECEDING`, `ROWS 5 PRECEDING`, etc.) which
8594    /// PG normalises to `BETWEEN <bound> AND CURRENT ROW`.
8595    fn parse_frame_tail(&mut self, kind: FrameKind) -> Result<WindowFrame, ParseError> {
8596        if matches!(self.peek(), Token::Between) {
8597            self.advance();
8598            let start = self.parse_frame_bound()?;
8599            if !matches!(self.peek(), Token::And) {
8600                return Err(self.err(format!("expected AND in frame spec, got {:?}", self.peek())));
8601            }
8602            self.advance();
8603            let end = self.parse_frame_bound()?;
8604            Ok(WindowFrame {
8605                kind,
8606                start,
8607                end: Some(end),
8608            })
8609        } else {
8610            let start = self.parse_frame_bound()?;
8611            Ok(WindowFrame {
8612                kind,
8613                start,
8614                end: None,
8615            })
8616        }
8617    }
8618
8619    /// Parse one frame bound: `UNBOUNDED PRECEDING`, `<n> PRECEDING`,
8620    /// `CURRENT ROW`, `<n> FOLLOWING`, `UNBOUNDED FOLLOWING`.
8621    fn parse_frame_bound(&mut self) -> Result<FrameBound, ParseError> {
8622        // Number-led: "<n> PRECEDING" / "<n> FOLLOWING".
8623        if let Token::Integer(n) = *self.peek() {
8624            self.advance();
8625            let n: u64 = u64::try_from(n).map_err(|_| {
8626                self.err(format!(
8627                    "invalid frame offset {n} — expected non-negative integer"
8628                ))
8629            })?;
8630            let dir = self.expect_ident_like()?;
8631            return if dir.eq_ignore_ascii_case("preceding") {
8632                Ok(FrameBound::OffsetPreceding(n))
8633            } else if dir.eq_ignore_ascii_case("following") {
8634                Ok(FrameBound::OffsetFollowing(n))
8635            } else {
8636                Err(self.err(format!(
8637                    "expected PRECEDING or FOLLOWING after offset, got {dir:?}"
8638                )))
8639            };
8640        }
8641        let first = self.expect_ident_like()?;
8642        if first.eq_ignore_ascii_case("unbounded") {
8643            let dir = self.expect_ident_like()?;
8644            return if dir.eq_ignore_ascii_case("preceding") {
8645                Ok(FrameBound::UnboundedPreceding)
8646            } else if dir.eq_ignore_ascii_case("following") {
8647                Ok(FrameBound::UnboundedFollowing)
8648            } else {
8649                Err(self.err(format!(
8650                    "expected PRECEDING or FOLLOWING after UNBOUNDED, got {dir:?}"
8651                )))
8652            };
8653        }
8654        if first.eq_ignore_ascii_case("current") {
8655            let row = self.expect_ident_like()?;
8656            if !row.eq_ignore_ascii_case("row") {
8657                return Err(self.err(format!("expected ROW after CURRENT, got {row:?}")));
8658            }
8659            return Ok(FrameBound::CurrentRow);
8660        }
8661        Err(self.err(format!(
8662            "expected frame bound (UNBOUNDED/CURRENT/<n>), got {first:?}"
8663        )))
8664    }
8665
8666    fn finish_ident_atom(&mut self, first: String) -> Result<Expr, ParseError> {
8667        if matches!(self.peek(), Token::Dot) {
8668            self.advance();
8669            let name = self.expect_ident_like()?;
8670            // v7.14.0 — schema-qualified function call
8671            // `<schema>.<fn>(args)`. PG dumps emit
8672            // `pg_catalog.set_config(...)` in the preamble. SPG
8673            // is single-namespace: drop the schema prefix and
8674            // route the dispatch on the bare function name.
8675            if matches!(self.peek(), Token::LParen) {
8676                return self.finish_ident_atom(name);
8677            }
8678            return Ok(Expr::Column(ColumnName {
8679                qualifier: Some(first),
8680                name,
8681            }));
8682        }
8683        if matches!(self.peek(), Token::LParen) {
8684            self.advance();
8685            // `COUNT(*)` — special-cased here because `*` isn't a normal
8686            // expression token. Lower-case match on `first` since the lexer
8687            // folds identifiers.
8688            if first.eq_ignore_ascii_case("count") && matches!(self.peek(), Token::Star) {
8689                self.advance();
8690                if !matches!(self.peek(), Token::RParen) {
8691                    return Err(self.err(format!(
8692                        "expected ')' after COUNT(*), got {:?}",
8693                        self.peek()
8694                    )));
8695                }
8696                self.advance();
8697                // v4.12: COUNT(*) OVER (...) — same window tail.
8698                let null_treatment = self.parse_null_treatment_modifier();
8699                if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8700                    && s.eq_ignore_ascii_case("over")
8701                {
8702                    self.advance();
8703                    let (partition_by, order_by, frame) = self.parse_over_clause()?;
8704                    return Ok(Expr::WindowFunction {
8705                        name: "count_star".into(),
8706                        args: Vec::new(),
8707                        partition_by,
8708                        order_by,
8709                        frame,
8710                        null_treatment,
8711                    });
8712                }
8713                return Ok(Expr::FunctionCall {
8714                    name: "count_star".into(),
8715                    args: Vec::new(),
8716                });
8717            }
8718            // Function call. PG-style: zero-or-more comma-separated args.
8719            let mut args = Vec::new();
8720            let mut agg_order_by: Vec<OrderBy> = Vec::new();
8721            if !matches!(self.peek(), Token::RParen) {
8722                loop {
8723                    args.push(self.parse_expr(0)?);
8724                    // v7.24 (round-16 A) — aggregate-internal
8725                    // ordering: `array_agg(x ORDER BY y DESC NULLS
8726                    // LAST)`. Keys close the argument list.
8727                    if matches!(self.peek(), Token::Order) {
8728                        self.advance();
8729                        if !matches!(self.peek(), Token::By) {
8730                            return Err(self.err(format!(
8731                                "expected BY after ORDER in aggregate args, got {:?}",
8732                                self.peek()
8733                            )));
8734                        }
8735                        self.advance();
8736                        loop {
8737                            let expr = self.parse_expr(0)?;
8738                            let desc = if matches!(self.peek(), Token::Desc) {
8739                                self.advance();
8740                                true
8741                            } else if matches!(self.peek(), Token::Asc) {
8742                                self.advance();
8743                                false
8744                            } else {
8745                                false
8746                            };
8747                            let nulls_first = self.parse_optional_nulls_placement()?;
8748                            agg_order_by.push(OrderBy {
8749                                expr,
8750                                desc,
8751                                nulls_first,
8752                            });
8753                            if matches!(self.peek(), Token::Comma) {
8754                                self.advance();
8755                            } else {
8756                                break;
8757                            }
8758                        }
8759                        if !matches!(self.peek(), Token::RParen) {
8760                            return Err(self.err(format!(
8761                                "expected ')' after aggregate ORDER BY, got {:?}",
8762                                self.peek()
8763                            )));
8764                        }
8765                        break;
8766                    }
8767                    match self.peek() {
8768                        Token::Comma => {
8769                            self.advance();
8770                        }
8771                        Token::RParen => break,
8772                        other => {
8773                            return Err(self.err(format!(
8774                                "expected ',' or ')' in function args, got {other:?}"
8775                            )));
8776                        }
8777                    }
8778                }
8779            }
8780            self.advance(); // consume ')'
8781            // v4.12: window-function tail — `name(args) OVER (...)`.
8782            // Promotes the just-parsed FunctionCall into a
8783            // WindowFunction node carrying partition + order.
8784            // v6.4.2: also accepts `name(args) IGNORE NULLS OVER (...)`
8785            // / `RESPECT NULLS OVER (...)` between the closing paren
8786            // and `OVER`.
8787            let null_treatment = self.parse_null_treatment_modifier();
8788            if let Token::Ident(s) | Token::QuotedIdent(s) = self.peek()
8789                && s.eq_ignore_ascii_case("over")
8790            {
8791                self.advance();
8792                let (partition_by, order_by, frame) = self.parse_over_clause()?;
8793                return Ok(Expr::WindowFunction {
8794                    name: first,
8795                    args,
8796                    partition_by,
8797                    order_by,
8798                    frame,
8799                    null_treatment,
8800                });
8801            }
8802            if !agg_order_by.is_empty() {
8803                return Ok(Expr::AggregateOrdered {
8804                    call: Box::new(Expr::FunctionCall { name: first, args }),
8805                    order_by: agg_order_by,
8806                });
8807            }
8808            return Ok(Expr::FunctionCall { name: first, args });
8809        }
8810        // v7.9.20 — SQL-standard parenless keyword expressions
8811        // (PG treats these as functions called without parens).
8812        // Resolve to a synthetic FunctionCall so the engine's
8813        // eval path reuses the existing function-call routing.
8814        // mailrs G3.
8815        let lc = first.to_ascii_lowercase();
8816        if matches!(
8817            lc.as_str(),
8818            "current_date" | "current_time" | "current_timestamp" | "localtimestamp" | "localtime"
8819        ) {
8820            return Ok(Expr::FunctionCall {
8821                name: lc,
8822                args: Vec::new(),
8823            });
8824        }
8825        Ok(Expr::Column(ColumnName {
8826            qualifier: None,
8827            name: first,
8828        }))
8829    }
8830}
8831
8832/// v6.8.2 — walk an expression tree and return the first column
8833/// reference's bare name. Used by `parse_create_index_stmt_after_create`
8834/// to derive `CreateIndexStatement.column` from an expression
8835/// key (so downstream planner code resolving a primary column
8836/// position keeps working with expression indexes). Returns
8837/// `None` when the expression has no column ref at all — caller
8838/// surfaces that as a parse error.
8839fn extract_first_column(expr: &Expr) -> Option<String> {
8840    match expr {
8841        Expr::Column(cn) => Some(cn.name.clone()),
8842        Expr::FunctionCall { args, .. } => args.iter().find_map(extract_first_column),
8843        Expr::Binary { lhs, rhs, .. } => {
8844            extract_first_column(lhs).or_else(|| extract_first_column(rhs))
8845        }
8846        Expr::Unary { expr: e, .. } => extract_first_column(e),
8847        _ => None,
8848    }
8849}
8850
8851fn maybe_not(expr: Expr, negated: bool) -> Expr {
8852    if negated {
8853        Expr::Unary {
8854            op: UnOp::Not,
8855            expr: Box::new(expr),
8856        }
8857    } else {
8858        expr
8859    }
8860}
8861
8862fn binop_from(tok: &Token) -> Option<(BinOp, u8)> {
8863    let pair = match tok {
8864        Token::Or => (BinOp::Or, 1),
8865        Token::And => (BinOp::And, 2),
8866        Token::Eq => (BinOp::Eq, 4),
8867        Token::NotEq => (BinOp::NotEq, 4),
8868        Token::Lt => (BinOp::Lt, 4),
8869        Token::LtEq => (BinOp::LtEq, 4),
8870        Token::Gt => (BinOp::Gt, 4),
8871        Token::GtEq => (BinOp::GtEq, 4),
8872        // pgvector distance ops all sit on the same rung — tighter than
8873        // comparisons (4) so `col <-> v < threshold` parses correctly.
8874        Token::L2Distance => (BinOp::L2Distance, 5),
8875        Token::InnerProduct => (BinOp::InnerProduct, 5),
8876        Token::CosineDistance => (BinOp::CosineDistance, 5),
8877        Token::Plus => (BinOp::Add, 6),
8878        Token::Minus => (BinOp::Sub, 6),
8879        // `||` sits beside `+`/`-` (matches PG conceptually — concat groups
8880        // by the same level as binary additive arithmetic).
8881        Token::Concat => (BinOp::Concat, 6),
8882        // Bitwise `|` / `&` ride the same rung as `||` — PG groups
8883        // all "other" operators between additive and comparison, so
8884        // `flags & $1 = 0` parses as `(flags & $1) = 0`.
8885        //
8886        // Known divergence (the same one `||` has carried since v1):
8887        // SPG's rung 6 TIES with `+ -`, while PG binds generic
8888        // operators LOOSER than additive — `a & b + 1` is
8889        // `(a & b) + 1` here vs `a & (b + 1)` in PG. Parenthesise
8890        // mixed bitwise/arithmetic. Keeping every generic operator
8891        // on one shared rung is deliberate: splitting bitwise off
8892        // would fix that case but skew `a || b & c`, which PG
8893        // left-folds at a single level.
8894        Token::Pipe => (BinOp::BitOr, 6),
8895        Token::Amp => (BinOp::BitAnd, 6),
8896        Token::Star => (BinOp::Mul, 7),
8897        Token::Slash => (BinOp::Div, 7),
8898        // v4.14: JSON path ops bind tighter than comparisons (4)
8899        // and additive (6) so `doc->'k' = 'v'` parses correctly.
8900        // Same rung as the multiplicative ops.
8901        Token::JsonGet => (BinOp::JsonGet, 7),
8902        Token::JsonGetText => (BinOp::JsonGetText, 7),
8903        Token::JsonGetPath => (BinOp::JsonGetPath, 7),
8904        Token::JsonGetPathText => (BinOp::JsonGetPathText, 7),
8905        Token::JsonContains => (BinOp::JsonContains, 7),
8906        // v7.12.2 — `@@` binds at the comparison rung (looser than
8907        // arithmetic, tighter than AND / OR). PG places `@@` at
8908        // the same precedence as `=` / `<`, so we follow.
8909        Token::TsMatch => (BinOp::TsMatch, 4),
8910        // v7.17.0 Phase 3.P0-47 — PG INET / CIDR containment + overlap.
8911        // PG places these at the comparison rung (same level as `=`),
8912        // so we follow.
8913        Token::InetContainedBy => (BinOp::InetContainedBy, 4),
8914        Token::InetContainedByEq => (BinOp::InetContainedByEq, 4),
8915        Token::InetContains => (BinOp::InetContains, 4),
8916        Token::InetContainsEq => (BinOp::InetContainsEq, 4),
8917        Token::InetOverlap => (BinOp::InetOverlap, 4),
8918        _ => return None,
8919    };
8920    Some(pair)
8921}
8922
8923#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
8924// `as f32` here is intentional: vector elements widen / narrow into f32 on
8925// purpose. i64 → f32 loses precision past 2^24, f64 → f32 loses precision
8926// past ~15 decimal digits — both are acceptable for a fixed-precision
8927// pgvector column.
8928/// v7.17.0 Phase 1.3 — words that would otherwise be eaten as an
8929/// implicit table alias and break trailing clauses. WITH lands
8930/// here so `… FROM t WITH NO DATA` doesn't consume WITH as the
8931/// alias for `t`; same for ON / WHERE / HAVING / GROUP / ORDER /
8932/// LIMIT / OFFSET / UNION / EXCEPT / INTERSECT / RETURNING / SET
8933/// / VALUES / FOR / LATERAL — all of which would otherwise be
8934/// silently swallowed by `parse_optional_alias`.
8935fn is_alias_stopword(s: &str) -> bool {
8936    matches!(
8937        s.to_ascii_lowercase().as_str(),
8938        "with"
8939            | "on"
8940            | "where"
8941            | "having"
8942            | "group"
8943            | "order"
8944            | "limit"
8945            | "offset"
8946            | "union"
8947            | "except"
8948            | "intersect"
8949            | "returning"
8950            | "set"
8951            | "values"
8952            | "for"
8953            | "lateral"
8954            | "left"
8955            | "right"
8956            | "inner"
8957            | "outer"
8958            | "full"
8959            | "cross"
8960            | "join"
8961            | "natural"
8962            | "using"
8963            | "fetch"
8964    )
8965}
8966
8967fn extract_numeric_literal(e: &Expr) -> Option<f32> {
8968    match e {
8969        Expr::Literal(Literal::Integer(n)) => Some(*n as f32),
8970        Expr::Literal(Literal::Float(x)) => Some(*x as f32),
8971        Expr::Unary {
8972            op: UnOp::Neg,
8973            expr,
8974        } => extract_numeric_literal(expr).map(|x| -x),
8975        _ => None,
8976    }
8977}
8978
8979/// Parse the text inside `INTERVAL '...'` into `(months, micros)`. Accepts
8980/// one or more `<n> <unit>` pairs separated by whitespace. `<n>` may be
8981/// negative. Returns `None` if any pair fails to parse or no pair is found.
8982///
8983/// Recognised units (case-insensitive, optional trailing `s`):
8984/// `microsecond`, `millisecond`, `second`, `minute`, `hour`, `day`, `week`,
8985/// `month`, `year`. `week` widens to 7 days; `year` widens to 12 months.
8986pub fn parse_interval_text(s: &str) -> Option<(i32, i64)> {
8987    let parts: Vec<&str> = s.split_whitespace().collect();
8988    if parts.is_empty() || !parts.len().is_multiple_of(2) {
8989        return None;
8990    }
8991    let mut months: i32 = 0;
8992    let mut micros: i64 = 0;
8993    let mut i = 0;
8994    while i < parts.len() {
8995        let n: i64 = parts[i].parse().ok()?;
8996        let unit = parts[i + 1].to_ascii_lowercase();
8997        let unit_stripped = unit.strip_suffix('s').unwrap_or(&unit);
8998        match unit_stripped {
8999            "microsecond" => micros = micros.checked_add(n)?,
9000            "millisecond" => micros = micros.checked_add(n.checked_mul(1_000)?)?,
9001            "second" => micros = micros.checked_add(n.checked_mul(1_000_000)?)?,
9002            "minute" => micros = micros.checked_add(n.checked_mul(60_000_000)?)?,
9003            "hour" => micros = micros.checked_add(n.checked_mul(3_600_000_000)?)?,
9004            "day" => micros = micros.checked_add(n.checked_mul(86_400_000_000)?)?,
9005            "week" => micros = micros.checked_add(n.checked_mul(604_800_000_000)?)?,
9006            "month" => {
9007                let n32 = i32::try_from(n).ok()?;
9008                months = months.checked_add(n32)?;
9009            }
9010            "year" => {
9011                let n32 = i32::try_from(n).ok()?;
9012                months = months.checked_add(n32.checked_mul(12)?)?;
9013            }
9014            _ => return None,
9015        }
9016        i += 2;
9017    }
9018    Some((months, micros))
9019}
9020
9021/// v7.12.4 — map a bare type-name identifier (the form that
9022/// appears in a function arg list or RETURNS clause) to a
9023/// [`ColumnTypeName`]. Returns `None` for unknown / extension
9024/// types so the caller can preserve them as
9025/// [`FunctionArgType::Raw`] / [`FunctionReturn::Other`].
9026///
9027/// Subset of the full column-type grammar — we deliberately
9028/// don't parse parameterised forms (`VARCHAR(n)`, `NUMERIC(p,s)`)
9029/// here because function-arg types in v7.12.4 are mostly the
9030/// bare form (`text`, `int`, `bytea`, …).
9031fn map_type_ident_to_column_type_name(ident: &str) -> Option<ColumnTypeName> {
9032    Some(match ident.to_ascii_lowercase().as_str() {
9033        "smallint" | "tinyint" => ColumnTypeName::SmallInt,
9034        "int" | "integer" | "mediumint" => ColumnTypeName::Int,
9035        "bigint" => ColumnTypeName::BigInt,
9036        "float" | "double" | "real" => ColumnTypeName::Float,
9037        "text" => ColumnTypeName::Text,
9038        "bool" | "boolean" => ColumnTypeName::Bool,
9039        "date" => ColumnTypeName::Date,
9040        "timestamp" | "datetime" => ColumnTypeName::Timestamp,
9041        "timestamptz" => ColumnTypeName::Timestamptz,
9042        "json" => ColumnTypeName::Json,
9043        "jsonb" => ColumnTypeName::Jsonb,
9044        "bytea" | "bytes" => ColumnTypeName::Bytes,
9045        "tsvector" => ColumnTypeName::TsVector,
9046        "tsquery" => ColumnTypeName::TsQuery,
9047        "uuid" => ColumnTypeName::Uuid,
9048        "time" => ColumnTypeName::Time,
9049        "year" => ColumnTypeName::Year,
9050        "timetz" => ColumnTypeName::TimeTz,
9051        "money" => ColumnTypeName::Money,
9052        _ => return None,
9053    })
9054}
9055
9056/// v7.12.4 — parse a PL/pgSQL function body (the bytes between
9057/// `$$ ... $$`). Returns the parsed `BEGIN ... END;` block.
9058///
9059/// v7.12.4 grammar (strict subset — IF / LOOP / DECLARE / RAISE
9060/// / embedded SQL land in v7.12.5+):
9061///
9062/// ```text
9063///   body          := [ws] block [ws]
9064///   block         := BEGIN stmt ( ; stmt )* [ ; ] END [ ; ]
9065///   stmt          := assign | return
9066///   assign        := assign_target := expr
9067///   assign_target := ( NEW | OLD ) . ident | ident
9068///   return        := RETURN ( NEW | OLD | NULL | expr )
9069/// ```
9070///
9071/// `expr` is parsed by recursing into the regular `Parser` — so a
9072/// PL/pgSQL `NEW.search_vector := to_tsvector('english',
9073/// NEW.subject || ' ' || NEW.sender)` body shape works without
9074/// the body parser knowing what `to_tsvector` is.
9075///
9076/// Errors here cause the caller to fall back to
9077/// `FunctionBody::Raw` — keeping the CREATE FUNCTION DDL itself
9078/// successful, but the executor will refuse to invoke the
9079/// function with an "unparseable body" error.
9080/// v7.12.4 — public alias for [`parse_plpgsql_body`] re-exported
9081/// from the crate root as `spg_sql::parse_function_body`.
9082pub fn parse_function_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9083    parse_plpgsql_body(body)
9084}
9085
9086fn parse_plpgsql_body(body: &str) -> Result<PlPgSqlBlock, ParseError> {
9087    // Use the regular lexer on the body text. The trailing
9088    // `END;` may or may not have a semicolon; the lexer treats
9089    // both forms identically.
9090    let tokens = lexer::tokenize(body).map_err(|e| ParseError {
9091        message: alloc::format!("plpgsql body lex error: {e}"),
9092        token_pos: 0,
9093    })?;
9094    let mut parser = Parser::new(tokens);
9095    parser.parse_plpgsql_block()
9096}
9097
9098#[cfg(test)]
9099mod tests {
9100    use super::*;
9101    use alloc::string::ToString;
9102
9103    fn parse(s: &str) -> Statement {
9104        parse_statement(s).expect("parse ok")
9105    }
9106
9107    fn lit_int(n: i64) -> Expr {
9108        Expr::Literal(Literal::Integer(n))
9109    }
9110
9111    fn col(name: &str) -> Expr {
9112        Expr::Column(ColumnName {
9113            qualifier: None,
9114            name: name.into(),
9115        })
9116    }
9117
9118    #[test]
9119    fn select_single_integer() {
9120        let s = parse("SELECT 1");
9121        let Statement::Select(s) = s else {
9122            panic!("expected SELECT")
9123        };
9124        assert_eq!(s.items.len(), 1);
9125        assert!(s.from.is_none());
9126        assert!(s.where_.is_none());
9127    }
9128
9129    #[test]
9130    fn select_multiple_literal_kinds() {
9131        let s = parse("SELECT 1, 'hi', NULL, TRUE, 1.5");
9132        let Statement::Select(s) = s else {
9133            panic!("expected SELECT")
9134        };
9135        assert_eq!(s.items.len(), 5);
9136    }
9137
9138    #[test]
9139    fn select_wildcard_from_table() {
9140        let s = parse("SELECT * FROM users");
9141        let Statement::Select(s) = s else {
9142            panic!("expected SELECT")
9143        };
9144        assert!(matches!(s.items[..], [SelectItem::Wildcard]));
9145        assert_eq!(s.from.as_ref().unwrap().primary.name, "users");
9146    }
9147
9148    #[test]
9149    fn select_with_table_alias() {
9150        let s = parse("SELECT * FROM users AS u");
9151        let Statement::Select(s) = s else {
9152            panic!("expected SELECT")
9153        };
9154        let t = &s.from.as_ref().unwrap().primary;
9155        assert_eq!(t.name, "users");
9156        assert_eq!(t.alias.as_deref(), Some("u"));
9157    }
9158
9159    #[test]
9160    fn select_with_where_eq() {
9161        let s = parse("SELECT a FROM t WHERE a = 1");
9162        let Statement::Select(s) = s else {
9163            panic!("expected SELECT")
9164        };
9165        let w = s.where_.unwrap();
9166        assert_eq!(
9167            w,
9168            Expr::Binary {
9169                lhs: Box::new(col("a")),
9170                op: BinOp::Eq,
9171                rhs: Box::new(lit_int(1)),
9172            }
9173        );
9174    }
9175
9176    #[test]
9177    fn arithmetic_precedence() {
9178        let s = parse("SELECT 1 + 2 * 3");
9179        let Statement::Select(s) = s else {
9180            panic!("expected SELECT")
9181        };
9182        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9183            panic!("wildcard?")
9184        };
9185        assert_eq!(
9186            expr,
9187            &Expr::Binary {
9188                lhs: Box::new(lit_int(1)),
9189                op: BinOp::Add,
9190                rhs: Box::new(Expr::Binary {
9191                    lhs: Box::new(lit_int(2)),
9192                    op: BinOp::Mul,
9193                    rhs: Box::new(lit_int(3)),
9194                }),
9195            }
9196        );
9197    }
9198
9199    #[test]
9200    fn parentheses_override_precedence() {
9201        let s = parse("SELECT (1 + 2) * 3");
9202        let Statement::Select(s) = s else {
9203            panic!("expected SELECT")
9204        };
9205        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9206            panic!()
9207        };
9208        assert_eq!(
9209            expr,
9210            &Expr::Binary {
9211                lhs: Box::new(Expr::Binary {
9212                    lhs: Box::new(lit_int(1)),
9213                    op: BinOp::Add,
9214                    rhs: Box::new(lit_int(2)),
9215                }),
9216                op: BinOp::Mul,
9217                rhs: Box::new(lit_int(3)),
9218            }
9219        );
9220    }
9221
9222    #[test]
9223    fn not_binds_below_comparison() {
9224        // `NOT a = 1` should parse as `NOT (a = 1)`.
9225        let s = parse("SELECT NOT a = 1 FROM t");
9226        let Statement::Select(s) = s else {
9227            panic!("expected SELECT")
9228        };
9229        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9230            panic!()
9231        };
9232        assert_eq!(
9233            expr,
9234            &Expr::Unary {
9235                op: UnOp::Not,
9236                expr: Box::new(Expr::Binary {
9237                    lhs: Box::new(col("a")),
9238                    op: BinOp::Eq,
9239                    rhs: Box::new(lit_int(1)),
9240                }),
9241            }
9242        );
9243    }
9244
9245    #[test]
9246    fn unary_minus_binds_above_multiplication() {
9247        // `-a * 2` should be `(-a) * 2`.
9248        let s = parse("SELECT -a * 2 FROM t");
9249        let Statement::Select(s) = s else {
9250            panic!("expected SELECT")
9251        };
9252        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9253            panic!()
9254        };
9255        assert_eq!(
9256            expr,
9257            &Expr::Binary {
9258                lhs: Box::new(Expr::Unary {
9259                    op: UnOp::Neg,
9260                    expr: Box::new(col("a")),
9261                }),
9262                op: BinOp::Mul,
9263                rhs: Box::new(lit_int(2)),
9264            }
9265        );
9266    }
9267
9268    #[test]
9269    fn qualified_column() {
9270        let s = parse("SELECT t.col FROM t");
9271        let Statement::Select(s) = s else {
9272            panic!("expected SELECT")
9273        };
9274        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9275            panic!()
9276        };
9277        assert_eq!(
9278            expr,
9279            &Expr::Column(ColumnName {
9280                qualifier: Some("t".into()),
9281                name: "col".into()
9282            })
9283        );
9284    }
9285
9286    #[test]
9287    fn select_item_alias_with_as() {
9288        let s = parse("SELECT a AS y FROM t");
9289        let Statement::Select(s) = s else {
9290            panic!("expected SELECT")
9291        };
9292        let SelectItem::Expr { alias, .. } = &s.items[0] else {
9293            panic!()
9294        };
9295        assert_eq!(alias.as_deref(), Some("y"));
9296    }
9297
9298    #[test]
9299    fn trailing_semicolon_accepted() {
9300        let s = parse("SELECT 1;");
9301        let Statement::Select(s) = s else {
9302            panic!("expected SELECT")
9303        };
9304        assert_eq!(s.items.len(), 1);
9305    }
9306
9307    #[test]
9308    fn boolean_chain_with_and_or_not() {
9309        // (NOT a) OR (b AND (NOT c))
9310        let s = parse("SELECT NOT a OR b AND NOT c FROM t");
9311        let Statement::Select(s) = s else {
9312            panic!("expected SELECT")
9313        };
9314        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9315            panic!()
9316        };
9317        let expected = Expr::Binary {
9318            lhs: Box::new(Expr::Unary {
9319                op: UnOp::Not,
9320                expr: Box::new(col("a")),
9321            }),
9322            op: BinOp::Or,
9323            rhs: Box::new(Expr::Binary {
9324                lhs: Box::new(col("b")),
9325                op: BinOp::And,
9326                rhs: Box::new(Expr::Unary {
9327                    op: UnOp::Not,
9328                    expr: Box::new(col("c")),
9329                }),
9330            }),
9331        };
9332        assert_eq!(expr, &expected);
9333    }
9334
9335    #[test]
9336    fn empty_input_errors() {
9337        // v7.14.0 — pg_dump preambles emit several comment-only
9338        // / blank-line statements that collapse to Statement::
9339        // Empty rather than a parse error. The old "SELECT in
9340        // message" assertion is stale; verify the new contract:
9341        // empty / whitespace / comment-only input parses to
9342        // Statement::Empty.
9343        assert!(matches!(parse_statement("").unwrap(), Statement::Empty));
9344        assert!(matches!(
9345            parse_statement("  \n\t ").unwrap(),
9346            Statement::Empty
9347        ));
9348        // Sanity: malformed-but-non-empty still errors.
9349        assert!(parse_statement("SELECT FROM WHERE").is_err());
9350    }
9351
9352    #[test]
9353    fn unmatched_paren_errors() {
9354        assert!(parse_statement("SELECT (1 + 2").is_err());
9355    }
9356
9357    #[test]
9358    fn display_round_trip_simple_select() {
9359        let original = parse("SELECT a + 1 FROM t WHERE a > 0");
9360        let text = original.to_string();
9361        let again = parse_statement(&text).expect("re-parse");
9362        assert_eq!(original, again);
9363    }
9364
9365    // --- CREATE TABLE & INSERT (v0.3) ---------------------------------------
9366
9367    #[test]
9368    fn create_table_single_column() {
9369        let s = parse("CREATE TABLE foo (a INT)");
9370        let Statement::CreateTable(c) = s else {
9371            panic!("expected CreateTable")
9372        };
9373        assert_eq!(c.name, "foo");
9374        assert_eq!(c.columns.len(), 1);
9375        assert_eq!(c.columns[0].name, "a");
9376        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9377        assert!(c.columns[0].nullable);
9378    }
9379
9380    #[test]
9381    fn create_table_multi_column_with_not_null_mix() {
9382        let s = parse("CREATE TABLE u (id INT NOT NULL, name TEXT, score FLOAT NOT NULL, ok BOOL)");
9383        let Statement::CreateTable(c) = s else {
9384            panic!()
9385        };
9386        assert_eq!(c.columns.len(), 4);
9387        assert_eq!(c.columns[0].ty, ColumnTypeName::Int);
9388        assert!(!c.columns[0].nullable);
9389        assert_eq!(c.columns[1].ty, ColumnTypeName::Text);
9390        assert!(c.columns[1].nullable);
9391        assert_eq!(c.columns[2].ty, ColumnTypeName::Float);
9392        assert!(!c.columns[2].nullable);
9393        assert_eq!(c.columns[3].ty, ColumnTypeName::Bool);
9394    }
9395
9396    #[test]
9397    fn create_table_bigint_supported() {
9398        let s = parse("CREATE TABLE accounts (id BIGINT NOT NULL)");
9399        let Statement::CreateTable(c) = s else {
9400            panic!()
9401        };
9402        assert_eq!(c.columns[0].ty, ColumnTypeName::BigInt);
9403    }
9404
9405    #[test]
9406    fn create_table_vector_default_is_f32() {
9407        let s = parse("CREATE TABLE t (v VECTOR(128))");
9408        let Statement::CreateTable(c) = s else {
9409            panic!()
9410        };
9411        assert_eq!(
9412            c.columns[0].ty,
9413            ColumnTypeName::Vector {
9414                dim: 128,
9415                encoding: VecEncoding::F32,
9416            },
9417        );
9418    }
9419
9420    #[test]
9421    fn create_table_vector_using_sq8() {
9422        // v6.0.1: `USING SQ8` selects scalar-quantised encoding.
9423        // Case-insensitive on both `USING` and the encoding name.
9424        for sql in [
9425            "CREATE TABLE t (v VECTOR(128) USING SQ8)",
9426            "CREATE TABLE t (v VECTOR(128) using sq8)",
9427        ] {
9428            let s = parse(sql);
9429            let Statement::CreateTable(c) = s else {
9430                panic!()
9431            };
9432            assert_eq!(
9433                c.columns[0].ty,
9434                ColumnTypeName::Vector {
9435                    dim: 128,
9436                    encoding: VecEncoding::Sq8,
9437                },
9438                "{sql}",
9439            );
9440        }
9441    }
9442
9443    #[test]
9444    fn create_table_vector_using_unknown_errors() {
9445        // v7.16.1 — the inline `USING <encoding>` shape on
9446        // CREATE TABLE column defs was withdrawn before
9447        // v7.14.0 in favour of `CREATE INDEX … USING hnsw
9448        // (col vector_<metric>_ops)`; the parser now rejects
9449        // USING at column-list position with a clearer
9450        // "expected ',' or ')'" message. Test asserts the
9451        // current rejection, not the old "unknown vector
9452        // encoding" string.
9453        let err = parse_statement("CREATE TABLE t (v VECTOR(8) USING PQ8)").unwrap_err();
9454        assert!(
9455            err.message.contains("USING")
9456                || err.message.contains("using")
9457                || err.message.contains("')'")
9458                || err.message.contains("','"),
9459            "expected USING/column-list rejection, got: {}",
9460            err.message
9461        );
9462    }
9463
9464    #[test]
9465    fn vector_using_sq8_display_roundtrips() {
9466        // The Display impl must produce text that re-parses to the
9467        // same AST. Guard for the v6.0.1 `USING SQ8` suffix.
9468        let s = parse("CREATE TABLE t (v VECTOR(64) USING SQ8)");
9469        let Statement::CreateTable(c) = s else {
9470            panic!()
9471        };
9472        assert_eq!(c.columns[0].ty.to_string(), "VECTOR(64) USING SQ8");
9473    }
9474
9475    #[test]
9476    fn parser_recognises_placeholders() {
9477        use crate::ast::{Expr, SelectItem, Statement};
9478        // $N in expression position parses as Expr::Placeholder(N).
9479        let s = parse("SELECT $1, $2 + 1 FROM t WHERE x = $3");
9480        let Statement::Select(sel) = s else { panic!() };
9481        assert!(matches!(
9482            sel.items[0],
9483            SelectItem::Expr {
9484                expr: Expr::Placeholder(1),
9485                alias: None
9486            }
9487        ));
9488        // $2 + 1
9489        let SelectItem::Expr {
9490            expr: Expr::Binary { lhs, rhs, .. },
9491            ..
9492        } = &sel.items[1]
9493        else {
9494            panic!()
9495        };
9496        assert!(matches!(**lhs, Expr::Placeholder(2)));
9497        assert!(matches!(**rhs, Expr::Literal(Literal::Integer(1))));
9498        // WHERE x = $3
9499        let Some(Expr::Binary { rhs, .. }) = sel.where_.as_ref() else {
9500            panic!()
9501        };
9502        assert!(matches!(**rhs, Expr::Placeholder(3)));
9503    }
9504
9505    #[test]
9506    fn parser_rejects_dollar_zero() {
9507        // $0 is not valid in PG; the lexer rejects it.
9508        assert!(parse_statement("SELECT $0").is_err());
9509    }
9510
9511    #[test]
9512    fn placeholder_display_roundtrips() {
9513        // The Display impl must produce text that re-lexes to the
9514        // same Placeholder token.
9515        let s = parse("SELECT $42 FROM t");
9516        let printed = s.to_string();
9517        assert!(printed.contains("$42"));
9518        let again = parse(&printed);
9519        assert_eq!(s, again);
9520    }
9521
9522    #[test]
9523    fn alter_index_rebuild_bare() {
9524        use crate::ast::{AlterIndexTarget, Statement};
9525        let s = parse("ALTER INDEX my_idx REBUILD");
9526        let Statement::AlterIndex(a) = s else {
9527            panic!("expected AlterIndex, got {s:?}")
9528        };
9529        assert_eq!(a.name, "my_idx");
9530        assert_eq!(a.target, AlterIndexTarget::Rebuild { encoding: None });
9531    }
9532
9533    #[test]
9534    fn alter_index_rebuild_with_encoding() {
9535        use crate::ast::{AlterIndexTarget, Statement};
9536        for (sql, want) in [
9537            (
9538                "ALTER INDEX my_idx REBUILD WITH (encoding = F32)",
9539                VecEncoding::F32,
9540            ),
9541            (
9542                "ALTER INDEX my_idx REBUILD WITH (encoding = sq8)",
9543                VecEncoding::Sq8,
9544            ),
9545            (
9546                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9547                VecEncoding::F16,
9548            ),
9549        ] {
9550            let s = parse(sql);
9551            let Statement::AlterIndex(a) = s else {
9552                panic!("{sql}: expected AlterIndex")
9553            };
9554            assert_eq!(a.name, "my_idx");
9555            assert_eq!(
9556                a.target,
9557                AlterIndexTarget::Rebuild {
9558                    encoding: Some(want)
9559                },
9560                "{sql}"
9561            );
9562        }
9563    }
9564
9565    #[test]
9566    fn alter_index_rebuild_unknown_encoding_errors() {
9567        let err = parse_statement("ALTER INDEX my_idx REBUILD WITH (encoding = PQ8)").unwrap_err();
9568        assert!(
9569            err.message.contains("unknown vector encoding"),
9570            "got: {}",
9571            err.message
9572        );
9573    }
9574
9575    #[test]
9576    fn alter_index_rebuild_display_roundtrips() {
9577        for (input, want) in [
9578            ("ALTER INDEX my_idx REBUILD", "ALTER INDEX my_idx REBUILD"),
9579            (
9580                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9581                "ALTER INDEX my_idx REBUILD WITH (encoding = SQ8)",
9582            ),
9583            (
9584                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9585                "ALTER INDEX my_idx REBUILD WITH (encoding = HALF)",
9586            ),
9587        ] {
9588            let s = parse(input);
9589            assert_eq!(s.to_string(), want);
9590        }
9591    }
9592
9593    #[test]
9594    fn create_table_unknown_type_defers_to_engine() {
9595        // v4.9 picked XML as a parse-time "unsupported column
9596        // type" probe. v7.17.0 Phase 1.4 changed the contract:
9597        // an unknown type ident parses as Text + `user_type_ref`
9598        // so CREATE TABLE can resolve user-defined enum / domain
9599        // types — rejection of truly-unknown types moved to the
9600        // engine's catalog lookup.
9601        let stmt = parse_statement("CREATE TABLE x (a xml)").unwrap();
9602        let Statement::CreateTable(t) = stmt else {
9603            panic!("expected CreateTable");
9604        };
9605        assert_eq!(t.columns[0].user_type_ref.as_deref(), Some("xml"));
9606    }
9607
9608    #[test]
9609    fn create_table_missing_table_keyword_errors() {
9610        assert!(parse_statement("CREATE x (a INT)").is_err());
9611    }
9612
9613    #[test]
9614    fn insert_single_value() {
9615        let s = parse("INSERT INTO foo VALUES (42)");
9616        let Statement::Insert(i) = s else {
9617            panic!("expected Insert")
9618        };
9619        assert_eq!(i.table, "foo");
9620        assert_eq!(i.rows.len(), 1);
9621        assert_eq!(i.rows[0].len(), 1);
9622        assert!(matches!(i.rows[0][0], Expr::Literal(Literal::Integer(42))));
9623    }
9624
9625    #[test]
9626    fn insert_multi_value_with_mixed_literals() {
9627        let s = parse("INSERT INTO foo VALUES (1, 'hi', 3.14, TRUE, NULL)");
9628        let Statement::Insert(i) = s else { panic!() };
9629        assert_eq!(i.rows.len(), 1);
9630        assert_eq!(i.rows[0].len(), 5);
9631    }
9632
9633    #[test]
9634    fn insert_missing_into_errors() {
9635        assert!(parse_statement("INSERT foo VALUES (1)").is_err());
9636    }
9637
9638    #[test]
9639    fn create_table_round_trip() {
9640        let original =
9641            parse("CREATE TABLE foo (id BIGINT NOT NULL, label TEXT, score FLOAT NOT NULL)");
9642        let text = original.to_string();
9643        let again = parse_statement(&text).expect("re-parse");
9644        assert_eq!(original, again);
9645    }
9646
9647    #[test]
9648    fn insert_round_trip_with_negation_and_string() {
9649        let original = parse("INSERT INTO t VALUES (-1, 'it''s', NULL)");
9650        let text = original.to_string();
9651        let again = parse_statement(&text).expect("re-parse");
9652        assert_eq!(original, again);
9653    }
9654
9655    #[test]
9656    fn unknown_keyword_at_statement_start_errors() {
9657        // v4.4: UPDATE is real SQL now. Use a fabricated keyword so
9658        // the top-level dispatch still has no branch to take.
9659        let err = parse_statement("FROBNICATE foo SET x = 1").unwrap_err();
9660        assert!(err.message.contains("expected SELECT"));
9661    }
9662
9663    // --- v0.8 CREATE INDEX --------------------------------------------------
9664
9665    #[test]
9666    fn create_index_basic() {
9667        let s = parse("CREATE INDEX idx_id ON users (id)");
9668        let Statement::CreateIndex(c) = s else {
9669            panic!("expected CreateIndex")
9670        };
9671        assert_eq!(c.name, "idx_id");
9672        assert_eq!(c.table, "users");
9673        assert_eq!(c.column, "id");
9674    }
9675
9676    #[test]
9677    fn create_index_missing_on_errors() {
9678        assert!(parse_statement("CREATE INDEX foo users (id)").is_err());
9679    }
9680
9681    #[test]
9682    fn create_index_missing_paren_errors() {
9683        assert!(parse_statement("CREATE INDEX foo ON users id").is_err());
9684    }
9685
9686    #[test]
9687    fn create_index_round_trip() {
9688        let original = parse("CREATE INDEX by_name ON users (name)");
9689        let again = parse_statement(&original.to_string()).unwrap();
9690        assert_eq!(original, again);
9691    }
9692
9693    // --- v7.9.29 CREATE UNIQUE INDEX [WHERE pred] (mailrs K1) -------------
9694
9695    #[test]
9696    fn create_unique_index_basic() {
9697        let s = parse("CREATE UNIQUE INDEX uq_x ON t (a)");
9698        let Statement::CreateIndex(c) = s else {
9699            panic!("expected CreateIndex");
9700        };
9701        assert!(c.is_unique);
9702        assert_eq!(c.column, "a");
9703        assert!(c.partial_predicate.is_none());
9704    }
9705
9706    #[test]
9707    fn create_unique_index_partial() {
9708        // mailrs's email_templates "one default per user" shape.
9709        let s = parse(
9710            "CREATE UNIQUE INDEX idx_email_templates_user_default \
9711             ON email_templates (user_address) WHERE is_default = true",
9712        );
9713        let Statement::CreateIndex(c) = s else {
9714            panic!("expected CreateIndex");
9715        };
9716        assert!(c.is_unique);
9717        assert_eq!(c.table, "email_templates");
9718        assert_eq!(c.column, "user_address");
9719        assert!(c.partial_predicate.is_some());
9720    }
9721
9722    #[test]
9723    fn create_unique_index_composite_with_predicate() {
9724        // mailrs's calendar_events instance: composite columns.
9725        let s = parse(
9726            "CREATE UNIQUE INDEX uq_calendar_events_instance \
9727             ON calendar_events (calendar_id, uid, recurrence_id) \
9728             WHERE recurrence_id IS NOT NULL",
9729        );
9730        let Statement::CreateIndex(c) = s else {
9731            panic!("expected CreateIndex");
9732        };
9733        assert!(c.is_unique);
9734        assert_eq!(c.column, "calendar_id");
9735        assert_eq!(
9736            c.extra_columns,
9737            vec!["uid".to_string(), "recurrence_id".to_string()]
9738        );
9739        assert!(c.partial_predicate.is_some());
9740    }
9741
9742    #[test]
9743    fn create_unique_index_using_btree_ok() {
9744        let s = parse("CREATE UNIQUE INDEX uq_x ON t USING btree (a)");
9745        assert!(matches!(s, Statement::CreateIndex(ref c) if c.is_unique));
9746    }
9747
9748    #[test]
9749    fn create_unique_index_using_hnsw_rejected() {
9750        let err =
9751            parse_statement("CREATE UNIQUE INDEX uq_v ON t USING hnsw (embedding)").unwrap_err();
9752        assert!(err.message.contains("UNIQUE"), "{}", err.message);
9753    }
9754
9755    #[test]
9756    fn create_unique_index_round_trip() {
9757        let original = parse(
9758            "CREATE UNIQUE INDEX uq_calendar_events_master \
9759             ON calendar_events (calendar_id, uid) WHERE recurrence_id IS NULL",
9760        );
9761        let again = parse_statement(&original.to_string()).unwrap();
9762        assert_eq!(original, again);
9763    }
9764
9765    #[test]
9766    fn create_unique_without_index_errors() {
9767        let err = parse_statement("CREATE UNIQUE TABLE t (a INT)").unwrap_err();
9768        assert!(err.message.contains("INDEX"), "{}", err.message);
9769    }
9770
9771    // --- v7.10.4 BYTES / BYTEA column type (Epic 1) ----------------------
9772
9773    #[test]
9774    fn create_table_bytea_column() {
9775        let s = parse("CREATE TABLE t (id INT NOT NULL, payload BYTEA NOT NULL)");
9776        let Statement::CreateTable(c) = s else {
9777            panic!("expected CreateTable");
9778        };
9779        assert_eq!(c.columns.len(), 2);
9780        assert_eq!(c.columns[1].ty, ColumnTypeName::Bytes);
9781        assert!(!c.columns[1].nullable);
9782    }
9783
9784    #[test]
9785    fn create_table_bytes_alias_column() {
9786        let s = parse("CREATE TABLE t (blob BYTES)");
9787        let Statement::CreateTable(c) = s else {
9788            panic!("expected CreateTable");
9789        };
9790        assert_eq!(c.columns[0].ty, ColumnTypeName::Bytes);
9791    }
9792
9793    #[test]
9794    fn bytea_round_trip_display() {
9795        let original = parse("CREATE TABLE t (a BYTEA NOT NULL)");
9796        let again = parse_statement(&original.to_string()).unwrap();
9797        assert_eq!(original, again);
9798    }
9799
9800    // --- v0.9 transactions -------------------------------------------------
9801
9802    #[test]
9803    fn begin_commit_rollback_parse_as_unit_variants() {
9804        assert_eq!(parse("BEGIN"), Statement::Begin);
9805        assert_eq!(parse("COMMIT"), Statement::Commit);
9806        assert_eq!(parse("ROLLBACK"), Statement::Rollback);
9807        // Trailing semicolons accepted too.
9808        assert_eq!(parse("BEGIN;"), Statement::Begin);
9809    }
9810
9811    // --- v1.2: pgvector distance ops + ::vector cast --------------------
9812
9813    #[test]
9814    fn inner_product_binop_parses() {
9815        let s = parse("SELECT v <#> [1.0, 2.0] FROM t");
9816        let Statement::Select(s) = s else { panic!() };
9817        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9818            panic!()
9819        };
9820        assert!(matches!(
9821            expr,
9822            Expr::Binary {
9823                op: BinOp::InnerProduct,
9824                ..
9825            }
9826        ));
9827    }
9828
9829    #[test]
9830    fn cosine_distance_binop_parses() {
9831        let s = parse("SELECT v <=> [1.0, 2.0] FROM t");
9832        let Statement::Select(s) = s else { panic!() };
9833        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9834            panic!()
9835        };
9836        assert!(matches!(
9837            expr,
9838            Expr::Binary {
9839                op: BinOp::CosineDistance,
9840                ..
9841            }
9842        ));
9843    }
9844
9845    #[test]
9846    fn vector_cast_postfix_wraps_string_literal() {
9847        let s = parse("SELECT '[1,2,3]'::vector FROM t");
9848        let Statement::Select(s) = s else { panic!() };
9849        let SelectItem::Expr { expr, .. } = &s.items[0] else {
9850            panic!()
9851        };
9852        assert!(matches!(
9853            expr,
9854            Expr::Cast {
9855                target: CastTarget::Vector,
9856                ..
9857            }
9858        ));
9859    }
9860
9861    #[test]
9862    fn unsupported_cast_target_errors() {
9863        // `::numeric` isn't in the v1.3 cast target set.
9864        let err = parse_statement("SELECT 1::numeric FROM t").unwrap_err();
9865        assert!(err.message.contains("unsupported cast target"));
9866    }
9867
9868    #[test]
9869    fn tx_statements_round_trip() {
9870        for q in ["BEGIN", "COMMIT", "ROLLBACK"] {
9871            let original = parse(q);
9872            let again = parse_statement(&original.to_string()).unwrap();
9873            assert_eq!(original, again);
9874        }
9875    }
9876
9877    #[test]
9878    fn interval_text_parsing_units() {
9879        // Single unit.
9880        assert_eq!(parse_interval_text("1 day"), Some((0, 86_400_000_000)));
9881        assert_eq!(parse_interval_text("1 second"), Some((0, 1_000_000)));
9882        assert_eq!(parse_interval_text("1 month"), Some((1, 0)));
9883        assert_eq!(parse_interval_text("2 years"), Some((24, 0)));
9884        // Compound spans accumulate.
9885        assert_eq!(parse_interval_text("1 year 6 months"), Some((18, 0)));
9886        assert_eq!(
9887            parse_interval_text("1 day 2 hours"),
9888            Some((0, 86_400_000_000 + 7_200_000_000))
9889        );
9890        // Negative numbers carry through.
9891        assert_eq!(parse_interval_text("-1 day"), Some((0, -86_400_000_000)));
9892        // Bad shapes return None.
9893        assert_eq!(parse_interval_text(""), None);
9894        assert_eq!(parse_interval_text("garbage"), None);
9895        assert_eq!(parse_interval_text("1 fortnight"), None);
9896        assert_eq!(parse_interval_text("1"), None);
9897    }
9898
9899    #[test]
9900    fn interval_literal_roundtrips_via_display() {
9901        let parsed = parse("SELECT INTERVAL '1 day 2 hours'");
9902        let s = parsed.to_string();
9903        // Display preserves the original text verbatim.
9904        assert!(s.contains("INTERVAL '1 day 2 hours'"), "got: {s}");
9905        // And re-parsing yields a structurally equal statement.
9906        let again = parse_statement(&s).unwrap();
9907        assert_eq!(parsed, again);
9908    }
9909
9910    // ── v6.1.2: CREATE / DROP PUBLICATION ────────────────────
9911
9912    #[test]
9913    fn parser_recognises_create_publication_bare() {
9914        let s = parse("CREATE PUBLICATION pub_a");
9915        let Statement::CreatePublication(p) = s else {
9916            panic!("expected CreatePublication, got {s:?}")
9917        };
9918        assert_eq!(p.name, "pub_a");
9919        assert_eq!(p.scope, PublicationScope::AllTables);
9920    }
9921
9922    #[test]
9923    fn parser_recognises_create_publication_for_all_tables() {
9924        let s = parse("CREATE PUBLICATION pub_a FOR ALL TABLES");
9925        let Statement::CreatePublication(p) = s else {
9926            panic!("expected CreatePublication, got {s:?}")
9927        };
9928        assert_eq!(p.name, "pub_a");
9929        assert_eq!(p.scope, PublicationScope::AllTables);
9930    }
9931
9932    #[test]
9933    fn parser_recognises_drop_publication() {
9934        let s = parse("DROP PUBLICATION pub_a");
9935        let Statement::DropPublication(name) = s else {
9936            panic!("expected DropPublication, got {s:?}")
9937        };
9938        assert_eq!(name, "pub_a");
9939    }
9940
9941    #[test]
9942    fn parser_recognises_for_table_list() {
9943        let s = parse("CREATE PUBLICATION pub_a FOR TABLE t1, t2, t3");
9944        let Statement::CreatePublication(p) = s else {
9945            panic!("expected CreatePublication, got {s:?}")
9946        };
9947        assert_eq!(p.name, "pub_a");
9948        let PublicationScope::ForTables(ts) = p.scope else {
9949            panic!("expected ForTables scope")
9950        };
9951        assert_eq!(ts, alloc::vec!["t1", "t2", "t3"]);
9952    }
9953
9954    #[test]
9955    fn parser_recognises_for_tables_plural() {
9956        // PG 19 accepts both `FOR TABLE` and `FOR TABLES` — match.
9957        let s = parse("CREATE PUBLICATION pub_a FOR TABLES t1, t2");
9958        let Statement::CreatePublication(p) = s else {
9959            panic!("expected CreatePublication, got {s:?}")
9960        };
9961        let PublicationScope::ForTables(ts) = p.scope else {
9962            panic!("expected ForTables")
9963        };
9964        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9965    }
9966
9967    #[test]
9968    fn parser_recognises_for_all_tables_except_list() {
9969        let s = parse("CREATE PUBLICATION p FOR ALL TABLES EXCEPT t1, t2");
9970        let Statement::CreatePublication(p) = s else {
9971            panic!()
9972        };
9973        let PublicationScope::AllTablesExcept(ts) = p.scope else {
9974            panic!("expected AllTablesExcept")
9975        };
9976        assert_eq!(ts, alloc::vec!["t1", "t2"]);
9977    }
9978
9979    #[test]
9980    fn parser_rejects_for_table_with_empty_list() {
9981        // `FOR TABLE` with nothing after is a parse error.
9982        let err = parse_statement("CREATE PUBLICATION p FOR TABLE")
9983            .expect_err("must error on empty list");
9984        // No specific message asserted — the call falls through to
9985        // expect_ident_like which yields "expected identifier, got …".
9986        assert!(!err.message.is_empty());
9987    }
9988
9989    #[test]
9990    fn parser_recognises_show_publications() {
9991        // v6.1.3 — SHOW PUBLICATIONS lands here. PUBLICATIONS is a
9992        // bare ident in this position, NOT a reserved keyword.
9993        let s = parse("SHOW PUBLICATIONS");
9994        assert!(matches!(s, Statement::ShowPublications));
9995    }
9996
9997    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW SUBSCRIPTIONS ─
9998
9999    #[test]
10000    fn parser_recognises_create_subscription_single_publication() {
10001        let s = parse(
10002            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
10003        );
10004        let Statement::CreateSubscription(c) = s else {
10005            panic!("expected CreateSubscription, got {s:?}")
10006        };
10007        assert_eq!(c.name, "sub_a");
10008        assert_eq!(c.conn_str, "host=127.0.0.1 port=20002");
10009        assert_eq!(c.publications, alloc::vec!["pub_a"]);
10010    }
10011
10012    #[test]
10013    fn parser_recognises_create_subscription_multi_publication() {
10014        let s = parse("CREATE SUBSCRIPTION sub_a CONNECTION 'host=h' PUBLICATION p1, p2, p3");
10015        let Statement::CreateSubscription(c) = s else {
10016            panic!()
10017        };
10018        assert_eq!(c.publications, alloc::vec!["p1", "p2", "p3"]);
10019    }
10020
10021    #[test]
10022    fn parser_rejects_create_subscription_missing_connection() {
10023        let err = parse_statement("CREATE SUBSCRIPTION s PUBLICATION p")
10024            .expect_err("must error on missing CONNECTION");
10025        assert!(err.message.contains("CONNECTION"), "got: {}", err.message);
10026    }
10027
10028    #[test]
10029    fn parser_rejects_create_subscription_missing_publication() {
10030        let err = parse_statement("CREATE SUBSCRIPTION s CONNECTION 'host=x'")
10031            .expect_err("must error on missing PUBLICATION");
10032        assert!(err.message.contains("PUBLICATION"), "got: {}", err.message);
10033    }
10034
10035    #[test]
10036    fn parser_recognises_drop_subscription() {
10037        let s = parse("DROP SUBSCRIPTION sub_a");
10038        let Statement::DropSubscription(name) = s else {
10039            panic!("expected DropSubscription, got {s:?}")
10040        };
10041        assert_eq!(name, "sub_a");
10042    }
10043
10044    #[test]
10045    fn parser_recognises_show_subscriptions() {
10046        let s = parse("SHOW SUBSCRIPTIONS");
10047        assert!(matches!(s, Statement::ShowSubscriptions));
10048    }
10049
10050    #[test]
10051    fn parser_recognises_wait_for_wal_position_no_timeout() {
10052        let s = parse("WAIT FOR WAL POSITION 12345");
10053        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
10054            panic!("expected WaitForWalPosition, got {s:?}")
10055        };
10056        assert_eq!(pos, 12345);
10057        assert!(timeout_ms.is_none());
10058    }
10059
10060    #[test]
10061    fn parser_recognises_wait_for_wal_position_with_timeout() {
10062        let s = parse("WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000");
10063        let Statement::WaitForWalPosition { pos, timeout_ms } = s else {
10064            panic!()
10065        };
10066        assert_eq!(pos, 67890);
10067        assert_eq!(timeout_ms, Some(5000));
10068    }
10069
10070    #[test]
10071    fn parser_rejects_wait_with_negative_position() {
10072        // The lexer treats `-` as a token; `expect_u64_literal`
10073        // only sees the Integer that follows, so the negative
10074        // arrives as a unary-minus expression at higher levels.
10075        // Bare `WAIT FOR WAL POSITION -1` thus surfaces as a
10076        // parse error one way or another.
10077        let err = parse_statement("WAIT FOR WAL POSITION -1").unwrap_err();
10078        assert!(!err.message.is_empty());
10079    }
10080
10081    #[test]
10082    fn parser_recognises_bare_analyze() {
10083        let s = parse("ANALYZE");
10084        assert!(matches!(s, Statement::Analyze(None)));
10085    }
10086
10087    #[test]
10088    fn parser_recognises_analyze_with_table() {
10089        let s = parse("ANALYZE users");
10090        let Statement::Analyze(Some(name)) = s else {
10091            panic!("expected Analyze, got {s:?}")
10092        };
10093        assert_eq!(name, "users");
10094    }
10095
10096    #[test]
10097    fn parser_recognises_analyze_with_quoted_table() {
10098        let s = parse("ANALYZE \"Mixed Case\"");
10099        let Statement::Analyze(Some(name)) = s else {
10100            panic!()
10101        };
10102        assert_eq!(name, "Mixed Case");
10103    }
10104
10105    #[test]
10106    fn parser_rejects_analyze_with_garbage_token() {
10107        let err = parse_statement("ANALYZE 42").expect_err("must error");
10108        assert!(!err.message.is_empty());
10109    }
10110
10111    #[test]
10112    fn analyze_display_roundtrips() {
10113        for sql in ["ANALYZE", "ANALYZE users"] {
10114            let s = parse(sql);
10115            let printed = s.to_string();
10116            let again = parse_statement(&printed)
10117                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10118            assert_eq!(s, again);
10119        }
10120    }
10121
10122    #[test]
10123    fn wait_for_display_roundtrips() {
10124        for sql in [
10125            "WAIT FOR WAL POSITION 12345",
10126            "WAIT FOR WAL POSITION 67890 WITH TIMEOUT 5000",
10127        ] {
10128            let s = parse(sql);
10129            let printed = s.to_string();
10130            let again = parse_statement(&printed)
10131                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10132            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10133        }
10134    }
10135
10136    #[test]
10137    fn subscription_ddl_display_roundtrips() {
10138        for sql in [
10139            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=h port=20002' PUBLICATION pub_a",
10140            "CREATE SUBSCRIPTION sub_b CONNECTION 'host=h' PUBLICATION p1, p2",
10141            "DROP SUBSCRIPTION sub_a",
10142            "SHOW SUBSCRIPTIONS",
10143        ] {
10144            let s = parse(sql);
10145            let printed = s.to_string();
10146            let again = parse_statement(&printed)
10147                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10148            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10149        }
10150    }
10151
10152    #[test]
10153    fn parser_drop_dispatches_user_vs_publication() {
10154        // Pre-v6.1.2 DROP USER took the bare-ident path; v6.1.2
10155        // tokenises DROP. Both targets must still parse.
10156        let s = parse("DROP USER 'alice'");
10157        let Statement::DropUser(name) = s else {
10158            panic!("expected DropUser, got {s:?}")
10159        };
10160        assert_eq!(name, "alice");
10161        // And DROP PUBLICATION lands the new variant.
10162        let s = parse("DROP PUBLICATION p1");
10163        assert!(matches!(s, Statement::DropPublication(_)));
10164    }
10165
10166    #[test]
10167    fn publication_ddl_display_roundtrips() {
10168        // Every CREATE PUBLICATION variant must Display → parse →
10169        // same AST. v6.1.3 covers all three scope shapes.
10170        for sql in [
10171            "CREATE PUBLICATION pub_a",
10172            "CREATE PUBLICATION pub_a FOR ALL TABLES",
10173            "CREATE PUBLICATION pub_a FOR TABLE t1, t2",
10174            "CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t1",
10175            "DROP PUBLICATION pub_a",
10176            "SHOW PUBLICATIONS",
10177        ] {
10178            let s = parse(sql);
10179            let printed = s.to_string();
10180            let again = parse_statement(&printed)
10181                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10182            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10183        }
10184    }
10185
10186    // --- v7.12.4: CREATE FUNCTION + CREATE TRIGGER + PL/pgSQL ---
10187
10188    #[test]
10189    fn create_function_returns_trigger_plpgsql_minimal() {
10190        let sql = "CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$";
10191        let s = parse(sql);
10192        let Statement::CreateFunction(f) = s else {
10193            panic!("expected CreateFunction");
10194        };
10195        assert_eq!(f.name, "noop");
10196        assert!(!f.or_replace);
10197        assert!(f.args.is_empty());
10198        assert!(matches!(f.returns, FunctionReturn::Trigger));
10199        assert_eq!(f.language, "plpgsql");
10200        let FunctionBody::PlPgSql(block) = f.body else {
10201            panic!("expected PlPgSql body");
10202        };
10203        assert_eq!(block.statements.len(), 1);
10204        assert!(matches!(
10205            block.statements[0],
10206            PlPgSqlStmt::Return(ReturnTarget::New)
10207        ));
10208    }
10209
10210    #[test]
10211    fn create_function_or_replace_with_assignment() {
10212        // mailrs-shape trigger function: NEW.col := to_tsvector(...);
10213        // RETURN NEW.
10214        let sql = "CREATE OR REPLACE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
10215BEGIN
10216  NEW.search_vector := to_tsvector('english', NEW.subject);
10217  RETURN NEW;
10218END;
10219$$";
10220        let s = parse(sql);
10221        let Statement::CreateFunction(f) = s else {
10222            panic!("expected CreateFunction");
10223        };
10224        assert!(f.or_replace);
10225        let FunctionBody::PlPgSql(block) = &f.body else {
10226            panic!("expected PlPgSql body");
10227        };
10228        assert_eq!(block.statements.len(), 2);
10229        // First statement: NEW.search_vector := to_tsvector(...)
10230        let PlPgSqlStmt::Assign { target, .. } = &block.statements[0] else {
10231            panic!("expected Assign as first stmt");
10232        };
10233        match target {
10234            AssignTarget::NewColumn(c) => assert_eq!(c, "search_vector"),
10235            other => panic!("expected NEW.col, got {other:?}"),
10236        }
10237        // Second statement: RETURN NEW
10238        assert!(matches!(
10239            block.statements[1],
10240            PlPgSqlStmt::Return(ReturnTarget::New)
10241        ));
10242    }
10243
10244    #[test]
10245    fn create_trigger_after_insert_or_update() {
10246        let sql = "CREATE TRIGGER tg AFTER INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()";
10247        let s = parse(sql);
10248        let Statement::CreateTrigger(t) = s else {
10249            panic!("expected CreateTrigger");
10250        };
10251        assert_eq!(t.name, "tg");
10252        assert_eq!(t.table, "messages");
10253        assert_eq!(t.timing, TriggerTiming::After);
10254        assert_eq!(t.events, vec![TriggerEvent::Insert, TriggerEvent::Update]);
10255        assert_eq!(t.for_each, TriggerForEach::Row);
10256        assert_eq!(t.function, "update_sv");
10257    }
10258
10259    #[test]
10260    fn create_trigger_before_delete_execute_procedure_alias() {
10261        // PG also accepts the legacy `EXECUTE PROCEDURE` spelling.
10262        let sql =
10263            "CREATE TRIGGER guard BEFORE DELETE ON t FOR EACH ROW EXECUTE PROCEDURE block_delete()";
10264        let s = parse(sql);
10265        let Statement::CreateTrigger(t) = s else {
10266            panic!("expected CreateTrigger");
10267        };
10268        assert_eq!(t.timing, TriggerTiming::Before);
10269        assert_eq!(t.events, vec![TriggerEvent::Delete]);
10270    }
10271
10272    #[test]
10273    fn drop_trigger_if_exists_round_trips() {
10274        // No parser support for DROP TRIGGER yet — added in v7.12.5
10275        // alongside the broader DROP …{IF EXISTS} cleanup. The
10276        // AST + Display impls are in place so we round-trip via
10277        // construction:
10278        let s = Statement::DropTrigger {
10279            name: "tg".into(),
10280            table: "messages".into(),
10281            if_exists: true,
10282        };
10283        assert_eq!(s.to_string(), "DROP TRIGGER IF EXISTS tg ON messages");
10284    }
10285
10286    #[test]
10287    fn trigger_ddl_display_roundtrips_through_parser() {
10288        // CREATE TRIGGER + its referenced CREATE FUNCTION must
10289        // Display → parse → same AST (modulo PL/pgSQL body
10290        // formatting which is parser-canonicalised).
10291        for sql in [
10292            "CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION f()",
10293            "CREATE TRIGGER tg2 BEFORE UPDATE OR DELETE ON t FOR EACH ROW EXECUTE FUNCTION g()",
10294        ] {
10295            let s = parse(sql);
10296            let printed = s.to_string();
10297            let again = parse_statement(&printed)
10298                .unwrap_or_else(|e| panic!("re-parse failed for {printed:?}: {e}"));
10299            assert_eq!(s, again, "round-trip mismatch for {sql:?}");
10300        }
10301    }
10302}