From 686762f579311f2b1a79e8d04bb65a096dcd8db2 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 2 Oct 2026 18:15:04 +0200 Subject: [PATCH 1/8] 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 | 81 ++++++++++++++++++++++++++++++++++++- 6 files changed, 155 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 8e798524c9..c01a2a6195 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, TypeModifier, }; 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::{ AggregateModifyKind, Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, @@ -8749,6 +8749,8 @@ pub enum ObjectType { Database, /// A role. Role, + /// A user group. + Group, /// A sequence. Sequence, /// A stage. @@ -8774,6 +8776,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 c867b76f61..d87a7e1619 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -443,6 +443,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 9e5257b30f..247b072048 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -355,6 +355,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 3efb22a349..35f31f34e2 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5497,7 +5497,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) { @@ -7088,9 +7092,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 ] @@ -7292,6 +7300,7 @@ impl<'a> Parser<'a> { } Ok(CreateRole { + keyword: role_keyword, names, if_not_exists, login, @@ -8021,6 +8030,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) { @@ -8064,7 +8077,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(), ); }; @@ -8080,9 +8093,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 aa9252125b..a2c957959d 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -25,7 +25,8 @@ mod test_utils; use helpers::attached_token::AttachedToken; use sqlparser::ast::*; use sqlparser::dialect::{ - Dialect, GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect, SnowflakeDialect, + Dialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect, + SnowflakeDialect, }; use sqlparser::parser::{Parser, ParserError}; use sqlparser::tokenizer::{Location, Span, Token, Tokenizer}; @@ -10875,3 +10876,81 @@ fn parse_create_aggregate_rejects_output_arguments_and_defaults() { ); } } + +#[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 1b20b687c1fcf5069e95202ea1aacbb2468d11f9 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 17 Oct 2026 00:37:24 +0200 Subject: [PATCH 2/8] 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 a2c957959d..31e078bb10 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10904,9 +10904,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] @@ -10947,10 +10950,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 b8af818cf89328e78ed45a38739126903b7dc7b5 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 09:33:35 +0200 Subject: [PATCH 3/8] 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 35f31f34e2..44f9b2c4a0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7097,6 +7097,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 31e078bb10..ad19ac0367 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10903,13 +10903,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 1a1ae1cc8632e8c251afd09c8656a6f064605242 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 10:09:37 +0200 Subject: [PATCH 4/8] 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 d87a7e1619..2643c8b487 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -457,6 +457,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 247b072048..9b7c2e2674 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -359,6 +359,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 44f9b2c4a0..965b672bcb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7097,15 +7097,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 f71869e820..64505c7960 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -10416,7 +10416,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 ad19ac0367..6141e98b01 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4740,11 +4740,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!(), } @@ -10967,3 +10967,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""#); +} From 92d9f4989cfe69827b28d7f533d89f3d40c4e364 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 00:29:20 +0200 Subject: [PATCH 5/8] Place regression tests beside related syntax cases --- tests/sqlparser_postgres.rs | 204 ++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 102 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 6141e98b01..e23eb8e3a4 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4835,6 +4835,108 @@ fn parse_create_role() { } } +#[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:?}"), + } + + assert_eq!( + pg().parse_sql_statements("CREATE GROUP IF NOT EXISTS g"), + Err(ParserError::ParserError( + "IF NOT EXISTS is not supported for CREATE GROUP".to_string() + )) + ); + pg().verified_stmt(r#"CREATE GROUP "IF""#); +} + +#[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, 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}" + ); + } +} + +#[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""#); +} + #[test] fn parse_alter_role() { let sql = "ALTER ROLE old_name RENAME TO new_name"; @@ -10876,105 +10978,3 @@ fn parse_create_aggregate_rejects_output_arguments_and_defaults() { ); } } - -#[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:?}"), - } - - assert_eq!( - pg().parse_sql_statements("CREATE GROUP IF NOT EXISTS g"), - Err(ParserError::ParserError( - "IF NOT EXISTS is not supported for CREATE GROUP".to_string() - )) - ); - pg().verified_stmt(r#"CREATE GROUP "IF""#); -} - -#[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, 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}" - ); - } -} - -#[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""#); -} From 893100ee6b756adbe7fe52c34a90e3b164898c09 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 12:18:24 +0200 Subject: [PATCH 6/8] Limit PostgreSQL group support to CREATE GROUP --- src/ast/dcl.rs | 7 +---- src/ast/mod.rs | 3 -- src/dialect/mod.rs | 13 ++------- src/dialect/postgresql.rs | 2 +- src/parser/mod.rs | 16 +++-------- tests/sqlparser_postgres.rs | 55 +++++-------------------------------- 6 files changed, 15 insertions(+), 81 deletions(-) diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index 9264ea541f..c716a80224 100644 --- a/src/ast/dcl.rs +++ b/src/ast/dcl.rs @@ -35,12 +35,7 @@ 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. -/// -/// +/// The object keyword in a `CREATE ROLE` or `CREATE GROUP` statement. #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/ast/mod.rs b/src/ast/mod.rs index c01a2a6195..373afeb5b7 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8749,8 +8749,6 @@ pub enum ObjectType { Database, /// A role. Role, - /// A user group. - Group, /// A sequence. Sequence, /// A stage. @@ -8776,7 +8774,6 @@ 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 2643c8b487..978bf57263 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -443,17 +443,8 @@ 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 { + /// Returns true if the dialect supports `CREATE GROUP`. + fn supports_create_group(&self) -> bool { false } diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 9b7c2e2674..76da98a9f7 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -355,7 +355,7 @@ impl Dialect for PostgreSqlDialect { true } - fn supports_user_group_statements(&self) -> bool { + fn supports_create_group(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 965b672bcb..e8a8ed5af4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5498,9 +5498,7 @@ impl<'a> Parser<'a> { self.parse_create_database() } else if self.parse_keyword(Keyword::ROLE) { self.parse_create_role(RoleKeyword::Role).map(Into::into) - } else if self.dialect.supports_user_group_statements() - && self.parse_keyword(Keyword::GROUP) - { + } else if self.dialect.supports_create_group() && 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) @@ -8040,10 +8038,6 @@ 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) { @@ -8087,7 +8081,7 @@ impl<'a> Parser<'a> { }; } else { return self.expected_ref( - "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", + "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", self.peek_token_ref(), ); }; @@ -8103,11 +8097,9 @@ impl<'a> Parser<'a> { if cascade && restrict { return parser_err!("Cannot specify both CASCADE and RESTRICT in DROP", loc); } - if matches!(object_type, ObjectType::Role | ObjectType::Group) - && (cascade || restrict || purge) - { + if object_type == ObjectType::Role && (cascade || restrict || purge) { return parser_err!( - format!("Cannot specify CASCADE, RESTRICT, or PURGE in DROP {object_type}"), + "Cannot specify CASCADE, RESTRICT, or PURGE in DROP ROLE", loc ); } diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index e23eb8e3a4..ca9a3267d2 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4871,59 +4871,18 @@ fn parse_create_group() { } #[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. +fn parse_create_group_is_dialect_gated() { let others = TestedDialects::new(vec![ Box::new(GenericDialect {}), Box::new(MySqlDialect {}), Box::new(MsSqlDialect {}), ]); - 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}" - ); - } + assert_eq!( + others.parse_sql_statements("CREATE GROUP staff"), + Err(ParserError::ParserError( + "Expected: an object type after CREATE, found: GROUP".to_string() + )) + ); } #[test] From a0bfd5e8e9178eacc82dc787a23067e72a32e1eb Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 12:40:50 +0200 Subject: [PATCH 7/8] Link PostgreSQL CREATE GROUP documentation --- src/dialect/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 978bf57263..a9cb4de557 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -444,6 +444,8 @@ pub trait Dialect: Debug + Any { } /// Returns true if the dialect supports `CREATE GROUP`. + /// + /// fn supports_create_group(&self) -> bool { false } From 20d98f88147c555806325e2d078779fb89d4910d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 13:43:25 +0200 Subject: [PATCH 8/8] Add fuzz seeds for pg create drop group --- fuzz/fuzz_seeds/postgres_2429_create_group.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 fuzz/fuzz_seeds/postgres_2429_create_group.sql diff --git a/fuzz/fuzz_seeds/postgres_2429_create_group.sql b/fuzz/fuzz_seeds/postgres_2429_create_group.sql new file mode 100644 index 0000000000..b243741374 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_2429_create_group.sql @@ -0,0 +1,4 @@ +CREATE GROUP g; +CREATE GROUP staff SUPERUSER LOGIN CONNECTION LIMIT 5 USER karl, john; +CREATE GROUP staff WITH SUPERUSER; +CREATE GROUP "IF"