From 5bbf9e360c170a2960740a60a4ff360bccf68865 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 27 Feb 2026 08:05:56 +0100 Subject: [PATCH 1/5] postgres: support UNLOGGED tables and SET LOGGED/UNLOGGED Add parser and AST support for PostgreSQL CREATE UNLOGGED TABLE and\nALTER TABLE ... SET LOGGED|UNLOGGED operations.\n\n- add LOGGED keyword\n- add CreateTable.unlogged and wire it through CreateTableBuilder\n- render UNLOGGED in CreateTable display\n- add AlterTableOperation::SetLogged and ::SetUnlogged display/spans\n- parse UNLOGGED only for PostgreSqlDialect|GenericDialect\n- parse ALTER TABLE SET LOGGED|UNLOGGED operations --- src/ast/ddl.rs | 19 ++++++++++++++++++- src/ast/helpers/stmt_create_table.rs | 10 ++++++++++ src/ast/spans.rs | 3 +++ src/keywords.rs | 1 + src/parser/mod.rs | 16 +++++++++++++++- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 67aefb3928..f135376549 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -442,6 +442,14 @@ pub enum AlterTableOperation { /// Table properties specified as SQL options. table_properties: Vec, }, + /// `SET LOGGED` + /// + /// Note: this is PostgreSQL-specific. + SetLogged, + /// `SET UNLOGGED` + /// + /// Note: this is PostgreSQL-specific. + SetUnlogged, /// `OWNER TO { | CURRENT_ROLE | CURRENT_USER | SESSION_USER }` /// /// Note: this is PostgreSQL-specific @@ -971,6 +979,12 @@ impl fmt::Display for AlterTableOperation { display_comma_separated(table_properties) ) } + AlterTableOperation::SetLogged => { + write!(f, "SET LOGGED") + } + AlterTableOperation::SetUnlogged => { + write!(f, "SET UNLOGGED") + } AlterTableOperation::FreezePartition { partition, with_name, @@ -2899,6 +2913,8 @@ pub struct CreateTable { pub or_replace: bool, /// `TEMP` or `TEMPORARY` clause pub temporary: bool, + /// `UNLOGGED` clause + pub unlogged: bool, /// `EXTERNAL` clause pub external: bool, /// `DYNAMIC` clause @@ -3073,7 +3089,7 @@ impl fmt::Display for CreateTable { // `CREATE TABLE t (a INT) AS SELECT a from t2` write!( f, - "CREATE {or_replace}{external}{global}{temporary}{transient}{volatile}{dynamic}{iceberg}{snapshot}TABLE {if_not_exists}{name}", + "CREATE {or_replace}{external}{global}{temporary}{unlogged}{transient}{volatile}{dynamic}{iceberg}{snapshot}TABLE {if_not_exists}{name}", or_replace = if self.or_replace { "OR REPLACE " } else { "" }, external = if self.external { "EXTERNAL " } else { "" }, snapshot = if self.snapshot { "SNAPSHOT " } else { "" }, @@ -3088,6 +3104,7 @@ impl fmt::Display for CreateTable { .unwrap_or(""), if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, temporary = if self.temporary { "TEMPORARY " } else { "" }, + unlogged = if self.unlogged { "UNLOGGED " } else { "" }, transient = if self.transient { "TRANSIENT " } else { "" }, volatile = if self.volatile { "VOLATILE " } else { "" }, // Only for Snowflake diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index ab2feb6930..a9daebdf7c 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -69,6 +69,8 @@ pub struct CreateTableBuilder { pub or_replace: bool, /// Whether the table is `TEMPORARY`. pub temporary: bool, + /// Whether the table is `UNLOGGED`. + pub unlogged: bool, /// Whether the table is `EXTERNAL`. pub external: bool, /// Optional `GLOBAL` flag for dialects that support it. @@ -191,6 +193,7 @@ impl CreateTableBuilder { Self { or_replace: false, temporary: false, + unlogged: false, external: false, global: None, if_not_exists: false, @@ -260,6 +263,11 @@ impl CreateTableBuilder { self.temporary = temporary; self } + /// Mark the table as `UNLOGGED`. + pub fn unlogged(mut self, unlogged: bool) -> Self { + self.unlogged = unlogged; + self + } /// Mark the table as `EXTERNAL`. pub fn external(mut self, external: bool) -> Self { self.external = external; @@ -561,6 +569,7 @@ impl CreateTableBuilder { CreateTable { or_replace: self.or_replace, temporary: self.temporary, + unlogged: self.unlogged, external: self.external, global: self.global, if_not_exists: self.if_not_exists, @@ -642,6 +651,7 @@ impl From for CreateTableBuilder { Self { or_replace: table.or_replace, temporary: table.temporary, + unlogged: table.unlogged, external: table.external, global: table.global, if_not_exists: table.if_not_exists, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index f0e3e252f5..fc4684ea3e 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -547,6 +547,7 @@ impl Spanned for CreateTable { let CreateTable { or_replace: _, // bool temporary: _, // bool + unlogged: _, // bool external: _, // bool global: _, // bool dynamic: _, // bool @@ -1212,6 +1213,8 @@ impl Spanned for AlterTableOperation { AlterTableOperation::SetTblProperties { table_properties } => { union_spans(table_properties.iter().map(|i| i.span())) } + AlterTableOperation::SetLogged => Span::empty(), + AlterTableOperation::SetUnlogged => Span::empty(), AlterTableOperation::OwnerTo { .. } => Span::empty(), AlterTableOperation::ClusterBy { exprs } => union_spans(exprs.iter().map(|e| e.span())), AlterTableOperation::DropClusteringKey => Span::empty(), diff --git a/src/keywords.rs b/src/keywords.rs index 4fc8f72d1d..1460a7fb54 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -596,6 +596,7 @@ define_keywords!( LOCK, LOCKED, LOG, + LOGGED, LOGIN, LOGS, LONG, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 668c520e5e..66a90a5123 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5137,14 +5137,18 @@ impl<'a> Parser<'a> { let temporary = self .parse_one_of_keywords(&[Keyword::TEMP, Keyword::TEMPORARY]) .is_some(); + let unlogged = dialect_of!(self is PostgreSqlDialect | GenericDialect) + && self.parse_keyword(Keyword::UNLOGGED); let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); let create_view_params = self.parse_create_view_params()?; if self.peek_keywords(&[Keyword::SNAPSHOT, Keyword::TABLE]) { self.parse_create_snapshot_table().map(Into::into) } else if self.parse_keyword(Keyword::TABLE) { - self.parse_create_table(or_replace, temporary, global, transient) + self.parse_create_table(or_replace, temporary, unlogged, global, transient) .map(Into::into) + } else if unlogged { + self.expected_ref("TABLE after UNLOGGED", self.peek_token_ref()) } else if self.peek_keyword(Keyword::MATERIALIZED) || self.peek_keyword(Keyword::VIEW) || self.peek_keywords(&[Keyword::SECURE, Keyword::MATERIALIZED, Keyword::VIEW]) @@ -8468,6 +8472,7 @@ impl<'a> Parser<'a> { &mut self, or_replace: bool, temporary: bool, + unlogged: bool, global: Option, transient: bool, ) -> Result { @@ -8628,6 +8633,7 @@ impl<'a> Parser<'a> { Ok(CreateTableBuilder::new(table_name) .temporary(temporary) + .unlogged(unlogged) .columns(columns) .constraints(constraints) .or_replace(or_replace) @@ -10664,6 +10670,14 @@ impl<'a> Parser<'a> { } else if self.parse_keywords(&[Keyword::VALIDATE, Keyword::CONSTRAINT]) { let name = self.parse_identifier()?; AlterTableOperation::ValidateConstraint { name } + } else if dialect_of!(self is PostgreSqlDialect | GenericDialect) + && self.parse_keywords(&[Keyword::SET, Keyword::LOGGED]) + { + AlterTableOperation::SetLogged + } else if dialect_of!(self is PostgreSqlDialect | GenericDialect) + && self.parse_keywords(&[Keyword::SET, Keyword::UNLOGGED]) + { + AlterTableOperation::SetUnlogged } else { let mut options = self.parse_options_with_keywords(&[Keyword::SET, Keyword::TBLPROPERTIES])?; From 27acd40b42100075c6493229ffadcd3ea48b2e89 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 27 Feb 2026 08:06:02 +0100 Subject: [PATCH 2/5] tests: cover unlogged table and logged state operations Add PostgreSQL regression coverage for the new syntax support and\nupdate existing struct-literal CreateTable expectations with the\nnew unlogged field in cross-dialect tests.\n\n- add parse_create_unlogged_table\n- add parse_alter_table_set_logged_unlogged\n- set unlogged defaults in duckdb/mssql fixture assertions --- tests/sqlparser_duckdb.rs | 1 + tests/sqlparser_mssql.rs | 2 + tests/sqlparser_postgres.rs | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index df62685808..56c2348e48 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -703,6 +703,7 @@ fn test_duckdb_union_datatype() { Statement::CreateTable(CreateTable { or_replace: Default::default(), temporary: Default::default(), + unlogged: Default::default(), external: Default::default(), global: Default::default(), if_not_exists: Default::default(), diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 1e053da78c..81e9d5ac84 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1924,6 +1924,7 @@ fn parse_create_table_with_valid_options() { Statement::CreateTable(CreateTable { or_replace: false, temporary: false, + unlogged: false, external: false, global: None, dynamic: false, @@ -2117,6 +2118,7 @@ fn parse_create_table_with_identity_column() { Statement::CreateTable(CreateTable { or_replace: false, temporary: false, + unlogged: false, external: false, global: None, dynamic: false, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 86315b1ef9..b062323233 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -1192,6 +1192,33 @@ fn parse_alter_table_owner_to() { ); } +#[test] +fn parse_alter_table_set_logged_unlogged() { + let sql = "ALTER TABLE unlogged1 SET LOGGED"; + match pg_and_generic().verified_stmt(sql) { + Statement::AlterTable(AlterTable { + name, operations, .. + }) => { + assert_eq!("unlogged1", name.to_string()); + assert_eq!(vec![AlterTableOperation::SetLogged], operations); + } + _ => unreachable!(), + } + pg_and_generic().one_statement_parses_to(sql, sql); + + let sql = "ALTER TABLE unlogged1 SET UNLOGGED"; + match pg_and_generic().verified_stmt(sql) { + Statement::AlterTable(AlterTable { + name, operations, .. + }) => { + assert_eq!("unlogged1", name.to_string()); + assert_eq!(vec![AlterTableOperation::SetUnlogged], operations); + } + _ => unreachable!(), + } + pg_and_generic().one_statement_parses_to(sql, sql); +} + #[test] fn parse_create_table_if_not_exists() { let sql = "CREATE TABLE IF NOT EXISTS uk_cities ()"; @@ -5692,6 +5719,51 @@ fn parse_create_table_with_partition_by() { } } +#[test] +fn parse_create_unlogged_table() { + let sql = "CREATE UNLOGGED TABLE public.unlogged2 (a int primary key)"; + match pg_and_generic().one_statement_parses_to( + sql, + "CREATE UNLOGGED TABLE public.unlogged2 (a INT PRIMARY KEY)", + ) { + Statement::CreateTable(CreateTable { name, unlogged, .. }) => { + assert!(unlogged); + assert_eq!("public.unlogged2", name.to_string()); + } + _ => unreachable!(), + } + + let sql = "CREATE UNLOGGED TABLE pg_temp.unlogged3 (a int primary key)"; + match pg_and_generic().one_statement_parses_to( + sql, + "CREATE UNLOGGED TABLE pg_temp.unlogged3 (a INT PRIMARY KEY)", + ) { + Statement::CreateTable(CreateTable { name, unlogged, .. }) => { + assert!(unlogged); + assert_eq!("pg_temp.unlogged3", name.to_string()); + } + _ => unreachable!(), + } + + let sql = "CREATE UNLOGGED TABLE unlogged1 (a int) PARTITION BY RANGE (a)"; + match pg_and_generic().one_statement_parses_to( + sql, + "CREATE UNLOGGED TABLE unlogged1 (a INT) PARTITION BY RANGE(a)", + ) { + Statement::CreateTable(CreateTable { + name, + unlogged, + partition_by, + .. + }) => { + assert!(unlogged); + assert_eq!("unlogged1", name.to_string()); + assert!(partition_by.is_some()); + } + _ => unreachable!(), + } +} + #[test] fn parse_join_constraint_unnest_alias() { assert_eq!( @@ -6638,6 +6710,7 @@ fn parse_trigger_related_functions() { CreateTable { or_replace: false, temporary: false, + unlogged: false, external: false, global: None, dynamic: false, From d85d3365fbb8ea7c3f1fb335c5291498a4214c51 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 1 Apr 2026 21:52:54 +0200 Subject: [PATCH 3/5] Extended test coverage --- tests/sqlparser_postgres.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index b062323233..f2a6d71434 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5762,6 +5762,13 @@ fn parse_create_unlogged_table() { } _ => unreachable!(), } + + // Negative test: UNLOGGED without TABLE should error + let res = pg().parse_sql_statements("CREATE UNLOGGED VIEW v AS SELECT 1"); + assert!( + res.is_err(), + "CREATE UNLOGGED should only be followed by TABLE" + ); } #[test] From a3930a03598e9aa85fdd396d274f8c8287b59afb Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 7 May 2026 20:50:42 +0200 Subject: [PATCH 4/5] Address UNLOGGED review comments --- src/parser/mod.rs | 18 +++++++----------- tests/sqlparser_common.rs | 24 ++++++++++++++++++++++++ tests/sqlparser_postgres.rs | 7 +++---- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 66a90a5123..7741831c10 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5137,18 +5137,18 @@ impl<'a> Parser<'a> { let temporary = self .parse_one_of_keywords(&[Keyword::TEMP, Keyword::TEMPORARY]) .is_some(); - let unlogged = dialect_of!(self is PostgreSqlDialect | GenericDialect) - && self.parse_keyword(Keyword::UNLOGGED); let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); let create_view_params = self.parse_create_view_params()?; if self.peek_keywords(&[Keyword::SNAPSHOT, Keyword::TABLE]) { self.parse_create_snapshot_table().map(Into::into) + } else if self.peek_keywords(&[Keyword::UNLOGGED, Keyword::TABLE]) { + self.expect_keywords(&[Keyword::UNLOGGED, Keyword::TABLE])?; + self.parse_create_table(or_replace, temporary, true, global, transient) + .map(Into::into) } else if self.parse_keyword(Keyword::TABLE) { - self.parse_create_table(or_replace, temporary, unlogged, global, transient) + self.parse_create_table(or_replace, temporary, false, global, transient) .map(Into::into) - } else if unlogged { - self.expected_ref("TABLE after UNLOGGED", self.peek_token_ref()) } else if self.peek_keyword(Keyword::MATERIALIZED) || self.peek_keyword(Keyword::VIEW) || self.peek_keywords(&[Keyword::SECURE, Keyword::MATERIALIZED, Keyword::VIEW]) @@ -10670,13 +10670,9 @@ impl<'a> Parser<'a> { } else if self.parse_keywords(&[Keyword::VALIDATE, Keyword::CONSTRAINT]) { let name = self.parse_identifier()?; AlterTableOperation::ValidateConstraint { name } - } else if dialect_of!(self is PostgreSqlDialect | GenericDialect) - && self.parse_keywords(&[Keyword::SET, Keyword::LOGGED]) - { + } else if self.parse_keywords(&[Keyword::SET, Keyword::LOGGED]) { AlterTableOperation::SetLogged - } else if dialect_of!(self is PostgreSqlDialect | GenericDialect) - && self.parse_keywords(&[Keyword::SET, Keyword::UNLOGGED]) - { + } else if self.parse_keywords(&[Keyword::SET, Keyword::UNLOGGED]) { AlterTableOperation::SetUnlogged } else { let mut options = diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 739238c822..72edacb663 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -18890,3 +18890,27 @@ fn parse_non_pg_dialects_keep_xml_names_as_regular_identifiers() { let dialects = all_dialects_except(|d| d.supports_xml_expressions()); dialects.verified_only_select("SELECT xml FROM t"); } + +#[test] +fn parse_unlogged_table_logging_controls_in_all_dialects() { + match all_dialects().verified_stmt("CREATE UNLOGGED TABLE t (a INT)") { + Statement::CreateTable(CreateTable { unlogged, .. }) => { + assert!(unlogged); + } + _ => unreachable!("Expected CREATE TABLE"), + } + + match all_dialects().verified_stmt("ALTER TABLE t SET LOGGED") { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(vec![AlterTableOperation::SetLogged], operations); + } + _ => unreachable!("Expected ALTER TABLE"), + } + + match all_dialects().verified_stmt("ALTER TABLE t SET UNLOGGED") { + Statement::AlterTable(AlterTable { operations, .. }) => { + assert_eq!(vec![AlterTableOperation::SetUnlogged], operations); + } + _ => unreachable!("Expected ALTER TABLE"), + } +} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index f2a6d71434..c724e71d06 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5763,11 +5763,10 @@ fn parse_create_unlogged_table() { _ => unreachable!(), } - // Negative test: UNLOGGED without TABLE should error let res = pg().parse_sql_statements("CREATE UNLOGGED VIEW v AS SELECT 1"); - assert!( - res.is_err(), - "CREATE UNLOGGED should only be followed by TABLE" + assert_eq!( + ParserError::ParserError("Expected: an object type after CREATE, found: UNLOGGED".into()), + res.unwrap_err() ); } From 73932f29d7cc368d5332958bbe3735843524545b Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 7 May 2026 21:06:17 +0200 Subject: [PATCH 5/5] Fix CREATE TABLE display after rebase --- src/ast/ddl.rs | 2 +- src/parser/mod.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index e2a99ad4b2..96e0630e7f 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3105,7 +3105,7 @@ impl fmt::Display for CreateTable { // `CREATE TABLE t (a INT) AS SELECT a from t2` write!( f, - "CREATE {or_replace}{external}{global}{temporary}{unlogged}{transient}{volatile}{dynamic}{iceberg}{snapshot}TABLE {if_not_exists}{name}", + "CREATE {or_replace}{external}{multiset}{global}{temporary}{unlogged}{transient}{volatile}{dynamic}{iceberg}{snapshot}TABLE {if_not_exists}{name}", or_replace = if self.or_replace { "OR REPLACE " } else { "" }, external = if self.external { "EXTERNAL " } else { "" }, snapshot = if self.snapshot { "SNAPSHOT " } else { "" }, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6cd88c6727..387939d410 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5146,8 +5146,10 @@ impl<'a> Parser<'a> { self.parse_create_snapshot_table().map(Into::into) } else if self.peek_keywords(&[Keyword::UNLOGGED, Keyword::TABLE]) { self.expect_keywords(&[Keyword::UNLOGGED, Keyword::TABLE])?; - self.parse_create_table(or_replace, temporary, true, global, transient) - .map(Into::into) + let mut create_table = self + .parse_create_table(or_replace, temporary, global, transient, volatile, multiset)?; + create_table.unlogged = true; + Ok(create_table.into()) } else if self.parse_keyword(Keyword::TABLE) { self.parse_create_table(or_replace, temporary, global, transient, volatile, multiset) .map(Into::into) @@ -8487,7 +8489,6 @@ impl<'a> Parser<'a> { &mut self, or_replace: bool, temporary: bool, - unlogged: bool, global: Option, transient: bool, volatile: bool, @@ -8669,7 +8670,7 @@ impl<'a> Parser<'a> { Ok(CreateTableBuilder::new(table_name) .temporary(temporary) - .unlogged(unlogged) + .unlogged(false) .columns(columns) .constraints(constraints) .or_replace(or_replace)