From 0e8d4337888b1f2f22b0d2c011e76e27df463e38 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 2 Oct 2026 18:15:04 +0200 Subject: [PATCH 1/4] PostgreSQL: parse `CREATE GROUP` and `DROP GROUP` --- src/ast/dcl.rs | 31 +++++++++++++- src/ast/mod.rs | 7 +++- src/dialect/mod.rs | 14 +++++++ src/dialect/postgresql.rs | 4 ++ src/parser/mod.rs | 29 +++++++++---- tests/sqlparser_postgres.rs | 82 ++++++++++++++++++++++++++++++++++++- 6 files changed, 156 insertions(+), 11 deletions(-) diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index 96b340714c..9264ea541f 100644 --- a/src/ast/dcl.rs +++ b/src/ast/dcl.rs @@ -35,6 +35,32 @@ use crate::ast::{ }; use crate::tokenizer::Span; +/// The keyword naming the object in a `CREATE`, `ALTER` or `DROP` role statement. +/// +/// PostgreSQL accepts `GROUP` as an obsolete spelling of `ROLE`, and Amazon Redshift has user +/// groups as objects distinct from roles. The keyword is preserved either way. +/// +/// +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum RoleKeyword { + /// `ROLE`, the general role object in both PostgreSQL and Redshift. + Role, + /// `GROUP`, an obsolete spelling of `ROLE` in PostgreSQL, and a user group + /// distinct from a role in Redshift. + Group, +} + +impl fmt::Display for RoleKeyword { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + RoleKeyword::Role => "ROLE", + RoleKeyword::Group => "GROUP", + }) + } +} + /// An option in `ROLE` statement. /// /// @@ -309,6 +335,8 @@ impl fmt::Display for SecondaryRoles { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct CreateRole { + /// Whether the statement was spelled `CREATE ROLE` or `CREATE GROUP`. + pub keyword: RoleKeyword, /// Role names to create. pub names: Vec, /// Whether `IF NOT EXISTS` was specified. @@ -353,7 +381,8 @@ impl fmt::Display for CreateRole { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "CREATE ROLE {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + "CREATE {keyword} {if_not_exists}{names}{superuser}{create_db}{create_role}{inherit}{login}{replication}{bypassrls}", + keyword = self.keyword, if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, names = display_separated(&self.names, ", "), superuser = match self.superuser { diff --git a/src/ast/mod.rs b/src/ast/mod.rs index eb5bc6cc75..151d424491 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -58,8 +58,8 @@ pub use self::data_type::{ ExactNumberInfo, IntervalFields, MapBracketKind, StructBracketKind, TimezoneInfo, }; pub use self::dcl::{ - AlterRoleOperation, CreateRole, Grant, ResetConfig, Revoke, RoleOption, SecondaryRoles, - SetConfigValue, Use, + AlterRoleOperation, CreateRole, Grant, ResetConfig, Revoke, RoleKeyword, RoleOption, + SecondaryRoles, SetConfigValue, Use, }; pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, @@ -8727,6 +8727,8 @@ pub enum ObjectType { Database, /// A role. Role, + /// A user group. + Group, /// A sequence. Sequence, /// A stage. @@ -8752,6 +8754,7 @@ impl fmt::Display for ObjectType { ObjectType::Schema => "SCHEMA", ObjectType::Database => "DATABASE", ObjectType::Role => "ROLE", + ObjectType::Group => "GROUP", ObjectType::Sequence => "SEQUENCE", ObjectType::Stage => "STAGE", ObjectType::Type => "TYPE", diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3ce71eb0be..ad8999e9e8 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -396,6 +396,20 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect has `CREATE GROUP` and `DROP GROUP` statements. + /// + /// PostgreSQL accepts `GROUP` as an obsolete spelling of `ROLE`, and Amazon Redshift has + /// user groups as objects distinct from roles. The keyword is preserved either way, as + /// [`RoleKeyword::Group`] and [`ObjectType::Group`]. + /// + /// + /// + /// [`RoleKeyword::Group`]: crate::ast::RoleKeyword::Group + /// [`ObjectType::Group`]: crate::ast::ObjectType::Group + fn supports_user_group_statements(&self) -> bool { + false + } + /// Returns true if the dialects supports `group sets, roll up, or cube` expressions. fn supports_group_by_expr(&self) -> bool { false diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index c060f2a8c1..47530a8bee 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -351,6 +351,10 @@ impl Dialect for PostgreSqlDialect { true } + fn supports_user_group_statements(&self) -> bool { + true + } + fn prec_value(&self, prec: Precedence) -> u8 { match prec { Precedence::Period => PERIOD_PREC, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 276b252ed2..9ed9edb027 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5455,7 +5455,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::DATABASE) { self.parse_create_database() } else if self.parse_keyword(Keyword::ROLE) { - self.parse_create_role().map(Into::into) + self.parse_create_role(RoleKeyword::Role).map(Into::into) + } else if self.dialect.supports_user_group_statements() + && self.parse_keyword(Keyword::GROUP) + { + self.parse_create_role(RoleKeyword::Group).map(Into::into) } else if self.parse_keyword(Keyword::SEQUENCE) { self.parse_create_sequence(temporary) } else if self.parse_keyword(Keyword::COLLATION) { @@ -7056,9 +7060,13 @@ impl<'a> Parser<'a> { } } - /// Parse a `CREATE ROLE` statement. - pub fn parse_create_role(&mut self) -> Result { - let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + /// Parse a `CREATE ROLE` or `CREATE GROUP` statement, after the keyword has been consumed. + pub fn parse_create_role( + &mut self, + role_keyword: RoleKeyword, + ) -> Result { + let if_not_exists = matches!(role_keyword, RoleKeyword::Role) + && self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; let _ = self.parse_keyword(Keyword::WITH); // [ WITH ] @@ -7260,6 +7268,7 @@ impl<'a> Parser<'a> { } Ok(CreateRole { + keyword: role_keyword, names, if_not_exists, login, @@ -7756,6 +7765,10 @@ impl<'a> Parser<'a> { ObjectType::Index } else if self.parse_keyword(Keyword::ROLE) { ObjectType::Role + } else if self.dialect.supports_user_group_statements() + && self.parse_keyword(Keyword::GROUP) + { + ObjectType::Group } else if self.parse_keyword(Keyword::SCHEMA) { ObjectType::Schema } else if self.parse_keyword(Keyword::DATABASE) { @@ -7799,7 +7812,7 @@ impl<'a> Parser<'a> { }; } else { return self.expected_ref( - "COLLATION, CONNECTOR, DATABASE, EXTENSION, FUNCTION, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW, USER or WAREHOUSE after DROP", + "COLLATION, CONNECTOR, DATABASE, EXTENSION, FUNCTION, GROUP, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW, USER or WAREHOUSE after DROP", self.peek_token_ref(), ); }; @@ -7815,9 +7828,11 @@ impl<'a> Parser<'a> { if cascade && restrict { return parser_err!("Cannot specify both CASCADE and RESTRICT in DROP", loc); } - if object_type == ObjectType::Role && (cascade || restrict || purge) { + if matches!(object_type, ObjectType::Role | ObjectType::Group) + && (cascade || restrict || purge) + { return parser_err!( - "Cannot specify CASCADE, RESTRICT, or PURGE in DROP ROLE", + format!("Cannot specify CASCADE, RESTRICT, or PURGE in DROP {object_type}"), loc ); } diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 0a3866d29f..68838e9c10 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -24,7 +24,9 @@ mod test_utils; use helpers::attached_token::AttachedToken; use sqlparser::ast::*; -use sqlparser::dialect::{Dialect, GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}; +use sqlparser::dialect::{ + Dialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect, +}; use sqlparser::parser::{Parser, ParserError}; use sqlparser::tokenizer::{Location, Span}; use test_utils::*; @@ -10265,3 +10267,81 @@ fn parse_geometric_typed_literal_error_propagates() { ParserError::ParserError("Expected: a value, found: (".to_string()) ); } + +#[test] +fn parse_create_group() { + // `GROUP` names the same object as `ROLE` in PostgreSQL, but the keyword is preserved. + pg().verified_stmt("CREATE GROUP g"); + pg().verified_stmt("CREATE GROUP staff SUPERUSER LOGIN CONNECTION LIMIT 5 USER karl, john"); + pg().one_statement_parses_to( + "CREATE GROUP staff WITH SUPERUSER", + "CREATE GROUP staff SUPERUSER", + ); + + match pg().verified_stmt("CREATE GROUP staff") { + Statement::CreateRole(create_role) => { + assert_eq!(create_role.keyword, RoleKeyword::Group); + assert_eq_vec(&["staff"], &create_role.names); + } + other => panic!("expected CREATE ROLE statement, got {other:?}"), + } + + // The `ROLE` spelling is unaffected. + match pg().verified_stmt("CREATE ROLE staff") { + Statement::CreateRole(create_role) => { + assert_eq!(create_role.keyword, RoleKeyword::Role) + } + other => panic!("expected CREATE ROLE statement, got {other:?}"), + } + + // `IF NOT EXISTS` belongs to `CREATE ROLE`, and PostgreSQL rejects it in `CREATE GROUP`. + assert!(pg() + .parse_sql_statements("CREATE GROUP IF NOT EXISTS g") + .is_err()); +} + +#[test] +fn parse_drop_group() { + pg().verified_stmt("DROP GROUP IF EXISTS staff, workers"); + + assert_eq!( + pg().verified_stmt("DROP GROUP staff"), + Statement::Drop { + object_type: ObjectType::Group, + if_exists: false, + names: vec![ObjectName::from(vec![Ident::new("staff")])], + cascade: false, + restrict: false, + purge: false, + temporary: false, + table: None, + } + ); + + // PostgreSQL rejects a drop behavior after `DROP GROUP`, exactly as after `DROP ROLE`. + assert_eq!( + pg().parse_sql_statements("DROP GROUP staff CASCADE") + .unwrap_err() + .to_string(), + "sql parser error: Cannot specify CASCADE, RESTRICT, or PURGE in DROP GROUP" + ); + + pg().verified_stmt("DROP ROLE staff"); +} + +#[test] +fn parse_user_group_statements_is_dialect_gated() { + // Only PostgreSQL opts in. Redshift documents the same statements and can turn the hook + // on, but its narrower `CREATE GROUP` option list is a separate question. + let others = TestedDialects::new(vec![ + Box::new(GenericDialect {}), + Box::new(MySqlDialect {}), + Box::new(MsSqlDialect {}), + ]); + for sql in ["CREATE GROUP staff", "DROP GROUP staff"] { + assert!( + others.parse_sql_statements(sql).is_err(), + "{sql} should not parse in a dialect without GROUP statements" + ); + } +} From 0dff74874b19adf81bd63c2c4f9cd5fc100ffaa3 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 17 Oct 2026 00:37:24 +0200 Subject: [PATCH 2/4] Assert exact errors for the new deny lints --- tests/sqlparser_postgres.rs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 68838e9c10..f0a095a670 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10295,9 +10295,12 @@ fn parse_create_group() { } // `IF NOT EXISTS` belongs to `CREATE ROLE`, and PostgreSQL rejects it in `CREATE GROUP`. - assert!(pg() - .parse_sql_statements("CREATE GROUP IF NOT EXISTS g") - .is_err()); + assert_eq!( + pg().parse_sql_statements("CREATE GROUP IF NOT EXISTS g"), + Err(ParserError::ParserError( + "Expected: end of statement, found: NOT".to_string() + )) + ); } #[test] @@ -10338,10 +10341,20 @@ fn parse_user_group_statements_is_dialect_gated() { Box::new(MySqlDialect {}), Box::new(MsSqlDialect {}), ]); - for sql in ["CREATE GROUP staff", "DROP GROUP staff"] { - assert!( - others.parse_sql_statements(sql).is_err(), - "{sql} should not parse in a dialect without GROUP statements" + for (sql, err) in [ + ( + "CREATE GROUP staff", + "Expected: an object type after CREATE, found: GROUP", + ), + ( + "DROP GROUP staff", + "Expected: COLLATION, CONNECTOR, DATABASE, EXTENSION, FUNCTION, GROUP, INDEX, OPERATOR, POLICY, PROCEDURE, ROLE, SCHEMA, SECRET, SEQUENCE, STAGE, TABLE, TRIGGER, TYPE, VIEW, MATERIALIZED VIEW, USER or WAREHOUSE after DROP, found: GROUP", + ), + ] { + assert_eq!( + others.parse_sql_statements(sql), + Err(ParserError::ParserError(err.to_string())), + "{sql}" ); } } From ede25a18b2674bb07b72b2948bef54c165592ddf Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 09:33:35 +0200 Subject: [PATCH 3/4] Report unsupported IF NOT EXISTS in CREATE GROUP --- src/parser/mod.rs | 8 ++++++++ tests/sqlparser_postgres.rs | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9ed9edb027..0f522310fd 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7065,6 +7065,14 @@ impl<'a> Parser<'a> { &mut self, role_keyword: RoleKeyword, ) -> Result { + if matches!(role_keyword, RoleKeyword::Group) + && self.peek_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]) + { + return parser_err!( + "IF NOT EXISTS is not supported for CREATE GROUP", + self.peek_token_ref().span.start + ); + } let if_not_exists = matches!(role_keyword, RoleKeyword::Role) && self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index f0a095a670..9f2f085fb6 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10294,13 +10294,13 @@ fn parse_create_group() { other => panic!("expected CREATE ROLE statement, got {other:?}"), } - // `IF NOT EXISTS` belongs to `CREATE ROLE`, and PostgreSQL rejects it in `CREATE GROUP`. assert_eq!( pg().parse_sql_statements("CREATE GROUP IF NOT EXISTS g"), Err(ParserError::ParserError( - "Expected: end of statement, found: NOT".to_string() + "IF NOT EXISTS is not supported for CREATE GROUP".to_string() )) ); + pg().verified_stmt(r#"CREATE GROUP "IF""#); } #[test] From 06ba2362c29364ed395bf6191f8b7c644119a9fa Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 10:09:37 +0200 Subject: [PATCH 4/4] Reject IF NOT EXISTS for PostgreSQL CREATE ROLE --- src/dialect/mod.rs | 5 +++++ src/dialect/postgresql.rs | 4 ++++ src/parser/mod.rs | 8 +++++--- tests/sqlparser_common.rs | 2 +- tests/sqlparser_postgres.rs | 15 +++++++++++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ad8999e9e8..b93a7608d6 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -410,6 +410,11 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if `CREATE ROLE` accepts `IF NOT EXISTS`. + fn supports_create_role_if_not_exists(&self) -> bool { + true + } + /// Returns true if the dialects supports `group sets, roll up, or cube` expressions. fn supports_group_by_expr(&self) -> bool { false diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 47530a8bee..c5746f0a75 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -355,6 +355,10 @@ impl Dialect for PostgreSqlDialect { true } + fn supports_create_role_if_not_exists(&self) -> bool { + false + } + fn prec_value(&self, prec: Precedence) -> u8 { match prec { Precedence::Period => PERIOD_PREC, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0f522310fd..49ce24b7a5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7065,15 +7065,17 @@ impl<'a> Parser<'a> { &mut self, role_keyword: RoleKeyword, ) -> Result { - if matches!(role_keyword, RoleKeyword::Group) + let supports_if_not_exists = matches!(role_keyword, RoleKeyword::Role) + && self.dialect.supports_create_role_if_not_exists(); + if !supports_if_not_exists && self.peek_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]) { return parser_err!( - "IF NOT EXISTS is not supported for CREATE GROUP", + format!("IF NOT EXISTS is not supported for CREATE {role_keyword}"), self.peek_token_ref().span.start ); } - let if_not_exists = matches!(role_keyword, RoleKeyword::Role) + let if_not_exists = supports_if_not_exists && self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 87fb61dc17..6692a9c33e 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -10078,7 +10078,7 @@ fn parse_create_role() { } let sql = "CREATE ROLE IF NOT EXISTS mysql_a, mysql_b"; - match verified_stmt(sql) { + match all_dialects_where(|d| d.supports_create_role_if_not_exists()).verified_stmt(sql) { Statement::CreateRole(create_role) => { assert_eq_vec(&["mysql_a", "mysql_b"], &create_role.names); assert!(create_role.if_not_exists); diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 9f2f085fb6..6192179eca 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4453,11 +4453,11 @@ fn parse_operator_empty_parens_rejected() { #[test] fn parse_create_role() { - let sql = "CREATE ROLE IF NOT EXISTS mysql_a, mysql_b"; + let sql = "CREATE ROLE mysql_a, mysql_b"; match pg().verified_stmt(sql) { Statement::CreateRole(create_role) => { assert_eq_vec(&["mysql_a", "mysql_b"], &create_role.names); - assert!(create_role.if_not_exists); + assert!(!create_role.if_not_exists); } _ => unreachable!(), } @@ -10358,3 +10358,14 @@ fn parse_user_group_statements_is_dialect_gated() { ); } } + +#[test] +fn parse_create_role_if_not_exists_rejected() { + assert_eq!( + pg().parse_sql_statements("CREATE ROLE IF NOT EXISTS r"), + Err(ParserError::ParserError( + "IF NOT EXISTS is not supported for CREATE ROLE".to_string() + )) + ); + pg().verified_stmt(r#"CREATE ROLE "IF""#); +}