From 920f85c699a9e2eaac9026000e1c96779e313e4a Mon Sep 17 00:00:00 2001 From: Ting-Hong Shieh <32212900+ting-hong-shieh@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:33:50 +0800 Subject: [PATCH] Postgres: Accept generated columns with no mode keyword or VIRTUAL PostgreSqlDialect required STORED after GENERATED ALWAYS AS (expr), so it rejected both the omitted mode and an explicit VIRTUAL. PostgreSQL 18 documents the grammar as GENERATED ALWAYS AS ( generation_expr ) [ STORED | VIRTUAL ], with VIRTUAL as the default when the keyword is omitted. Remove the PostgreSqlDialect special case so the dialect falls back to the shared handling, which parses both forms and round-trips them unchanged. --- src/parser/mod.rs | 13 +++++-------- tests/sqlparser_postgres.rs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b2b3f42bb..7fc251858 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9827,18 +9827,15 @@ impl<'a> Parser<'a> { let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_expr())?; self.expect_token(&Token::RParen)?; let (gen_as, expr_mode) = if self.parse_keywords(&[Keyword::STORED]) { - Ok(( + ( GeneratedAs::ExpStored, Some(GeneratedExpressionMode::Stored), - )) - } else if dialect_of!(self is PostgreSqlDialect) { - // Postgres' AS IDENTITY branches are above, this one needs STORED - self.expected_ref("STORED", self.peek_token_ref()) + ) } else if self.parse_keywords(&[Keyword::VIRTUAL]) { - Ok((GeneratedAs::Always, Some(GeneratedExpressionMode::Virtual))) + (GeneratedAs::Always, Some(GeneratedExpressionMode::Virtual)) } else { - Ok((GeneratedAs::Always, None)) - }?; + (GeneratedAs::Always, None) + }; Ok(Some(ColumnOption::Generated { generated_as: gen_as, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index a7128eafd..36d58b3cf 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9663,3 +9663,26 @@ fn parse_right_deep_join_chain() { // NATURAL JOIN followed by a constrained join must stay left-associative. pg().verified_stmt("SELECT * FROM t0 NATURAL JOIN t1 INNER JOIN t2 ON true"); } + +#[test] +fn parse_create_table_generated_column_modes() { + // PostgreSQL 18 generated columns are virtual by default, so the mode keyword is + // optional and VIRTUAL may also be written explicitly. All three forms round-trip + // without gaining or losing a mode keyword. + pg().verified_stmt("CREATE TABLE t (a TEXT, b TEXT GENERATED ALWAYS AS (a))"); + pg().verified_stmt("CREATE TABLE t (a TEXT, b TEXT GENERATED ALWAYS AS (a) VIRTUAL)"); + pg().verified_stmt("CREATE TABLE t (a TEXT, b TEXT GENERATED ALWAYS AS (a) STORED)"); + + // PostgreSQL 18 pg_dump emits generated virtual columns with no mode keyword. + pg().one_statement_parses_to( + r#"CREATE TABLE users ( + first_name text NOT NULL, + last_name text NOT NULL, + name character varying(255) GENERATED ALWAYS AS (((first_name || ' '::text) || last_name)) NOT NULL +)"#, + "CREATE TABLE users (\ + first_name TEXT NOT NULL, \ + last_name TEXT NOT NULL, \ + name CHARACTER VARYING(255) GENERATED ALWAYS AS (((first_name || ' '::TEXT) || last_name)) NOT NULL)", + ); +}