From d7f2372105c082c7345b5f486df39585c26fe2b3 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 5 Aug 2026 15:30:40 +0200 Subject: [PATCH] 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 | 26 ++++++++++--- tests/sqlparser_postgres.rs | 77 ++++++++++++++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 10 deletions(-) diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index 3c50a81c06..d94b8dec69 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 8a9a67a74d..67a06cc764 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, @@ -8656,6 +8656,8 @@ pub enum ObjectType { Database, /// A role. Role, + /// A user group. + Group, /// A sequence. Sequence, /// A stage. @@ -8681,6 +8683,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 f99cbe2eaf..ffb6dfd82d 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -378,6 +378,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 d342276e44..7e29178919 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -165,6 +165,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 b2b3f42bbf..e15413de4e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5304,7 +5304,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) { @@ -6884,8 +6888,11 @@ impl<'a> Parser<'a> { } } - /// Parse a `CREATE ROLE` statement. - pub fn parse_create_role(&mut self) -> Result { + /// 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 = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let names = self.parse_comma_separated(|p| p.parse_object_name(false))?; @@ -7088,6 +7095,7 @@ impl<'a> Parser<'a> { } Ok(CreateRole { + keyword: role_keyword, names, if_not_exists, login, @@ -7587,6 +7595,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) { @@ -7630,7 +7642,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(), ); }; @@ -7646,9 +7658,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 a7128eafd8..a9a6adc405 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::ParserError; use sqlparser::tokenizer::Span; use test_utils::*; @@ -9663,3 +9665,76 @@ 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_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:?}"), + } +} + +#[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" + ); + } +}