From 8b127dc4e8142a77e6ee358c2eecc7d95a9c377d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 27 Oct 2026 09:48:23 +0100 Subject: [PATCH 1/2] PostgreSQL: Parse WITH options of a role membership GRANT --- .../postgres_grant_role_options.sql | 1 + src/ast/dcl.rs | 47 ++++++++++++++++++- src/ast/mod.rs | 4 +- src/parser/mod.rs | 18 +++++++ tests/sqlparser_common.rs | 41 ++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 fuzz/fuzz_seeds/postgres_grant_role_options.sql diff --git a/fuzz/fuzz_seeds/postgres_grant_role_options.sql b/fuzz/fuzz_seeds/postgres_grant_role_options.sql new file mode 100644 index 0000000000..c361ad62cb --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_grant_role_options.sql @@ -0,0 +1 @@ +GRANT bypasser TO anon WITH ADMIN OPTION, INHERIT FALSE, SET FALSE GRANTED BY admin diff --git a/src/ast/dcl.rs b/src/ast/dcl.rs index ad4cc3a6b5..70d3a1563a 100644 --- a/src/ast/dcl.rs +++ b/src/ast/dcl.rs @@ -536,7 +536,7 @@ impl From for crate::ast::Statement { } } -/// `GRANT role [, ...] TO grantee [, ...] [GRANTED BY grantor]` +/// `GRANT role [, ...] TO grantee [, ...] [WITH option [, ...]] [GRANTED BY grantor]` /// /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-grant.html) #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] @@ -547,6 +547,8 @@ pub struct GrantRole { pub roles: Vec, /// Roles receiving the membership. pub grantees: Vec, + /// Options of the membership, from `WITH`. + pub options: Vec, /// Optional `GRANTED BY` identifier. pub granted_by: Option, } @@ -559,6 +561,9 @@ impl fmt::Display for GrantRole { display_comma_separated(&self.roles), display_comma_separated(&self.grantees) )?; + if !self.options.is_empty() { + write!(f, " WITH {}", display_comma_separated(&self.options))?; + } if let Some(granted_by) = &self.granted_by { write!(f, " GRANTED BY {granted_by}")?; } @@ -571,3 +576,43 @@ impl From for crate::ast::Statement { crate::ast::Statement::GrantRole(v) } } + +/// An option of a role membership, such as `ADMIN OPTION` or `SET FALSE`. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct RoleGrantOption { + /// The option name, such as `ADMIN`, `INHERIT` or `SET`. + pub name: Ident, + /// The value it is set to. + pub value: RoleGrantOptionValue, +} + +impl fmt::Display for RoleGrantOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} {}", self.name, self.value) + } +} + +/// The value of a [`RoleGrantOption`]. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum RoleGrantOptionValue { + /// `OPTION`, equivalent to `TRUE`. + Option, + /// `TRUE` + True, + /// `FALSE` + False, +} + +impl fmt::Display for RoleGrantOptionValue { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(match self { + RoleGrantOptionValue::Option => "OPTION", + RoleGrantOptionValue::True => "TRUE", + RoleGrantOptionValue::False => "FALSE", + }) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 8dfb8ad845..9b5441738a 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, GrantRole, ResetConfig, Revoke, RoleOption, - SecondaryRoles, SetConfigValue, Use, + AlterRoleOperation, CreateRole, Grant, GrantRole, ResetConfig, Revoke, RoleGrantOption, + RoleGrantOptionValue, RoleOption, SecondaryRoles, SetConfigValue, Use, }; pub use self::ddl::{ AggregateModifyKind, Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9012270f18..c5a6f4e854 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18450,6 +18450,11 @@ impl<'a> Parser<'a> { let roles = self.parse_comma_separated(Parser::parse_identifier)?; self.expect_keyword_is(Keyword::TO)?; let grantees = self.parse_grantees()?; + let options = if self.parse_keyword(Keyword::WITH) { + self.parse_comma_separated(Parser::parse_role_grant_option)? + } else { + vec![] + }; let granted_by = if self.parse_keywords(&[Keyword::GRANTED, Keyword::BY]) { Some(self.parse_identifier()?) } else { @@ -18458,10 +18463,23 @@ impl<'a> Parser<'a> { Ok(GrantRole { roles, grantees, + options, granted_by, }) } + fn parse_role_grant_option(&mut self) -> Result { + let name = self.parse_identifier()?; + let value = + match self.parse_one_of_keywords(&[Keyword::OPTION, Keyword::TRUE, Keyword::FALSE]) { + Some(Keyword::OPTION) => RoleGrantOptionValue::Option, + Some(Keyword::TRUE) => RoleGrantOptionValue::True, + Some(Keyword::FALSE) => RoleGrantOptionValue::False, + _ => return self.expected_ref("OPTION, TRUE or FALSE", self.peek_token_ref()), + }; + Ok(RoleGrantOption { name, value }) + } + /// Parses a statement body with `privileges`, and with `roles` instead when that fails and /// the body opens with a role list followed by `keyword`. fn parse_privileges_or_roles, R: Into>( diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 2e0a814ea4..4a59c60c73 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -22402,6 +22402,7 @@ fn parse_grant_role() { Statement::GrantRole(GrantRole { roles: vec![Ident::new("bypasser"), Ident::new("auditor")], grantees: vec![role_grantee("anon"), role_grantee("authenticated")], + options: vec![], granted_by: Some(Ident::new("admin")), }) ); @@ -22460,3 +22461,43 @@ fn display_grant_role_propagates_formatter_errors() { assert_eq!(write!(writer, "{grant}"), Err(fmt::Error), "{rejected}"); } } + +#[test] +fn parse_grant_role_options() { + let sql = "GRANT bypasser TO anon WITH ADMIN OPTION, INHERIT FALSE, SET TRUE GRANTED BY admin"; + let Statement::GrantRole(GrantRole { options, .. }) = all_dialects().verified_stmt(sql) else { + panic!("Expected GRANT role"); + }; + assert_eq!( + options, + vec![ + RoleGrantOption { + name: Ident::new("ADMIN"), + value: RoleGrantOptionValue::Option, + }, + RoleGrantOption { + name: Ident::new("INHERIT"), + value: RoleGrantOptionValue::False, + }, + RoleGrantOption { + name: Ident::new("SET"), + value: RoleGrantOptionValue::True, + }, + ] + ); + + all_dialects().verified_stmt("GRANT bypasser TO anon WITH SET FALSE"); + + assert_eq!( + all_dialects() + .parse_sql_statements("GRANT bypasser TO anon WITH ADMIN") + .unwrap_err(), + ParserError::ParserError("Expected: OPTION, TRUE or FALSE, found: EOF".to_string()) + ); + assert_eq!( + all_dialects() + .parse_sql_statements("GRANT bypasser TO anon WITH 1 OPTION") + .unwrap_err(), + ParserError::ParserError("Expected: identifier, found: 1".to_string()) + ); +} From a4e71c0a7313203c5b36a836c1a2943028d82226 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 08:44:18 +0100 Subject: [PATCH 2/2] Tests: Cover role-grant option Display propagation --- tests/sqlparser_common.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 4a59c60c73..018c3d8fc5 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -22501,3 +22501,26 @@ fn parse_grant_role_options() { ParserError::ParserError("Expected: identifier, found: 1".to_string()) ); } + +#[test] +fn display_grant_role_options_propagates_write_errors() { + use core::fmt::{self, Write}; + + struct RejectingWriter<'a>(&'a str); + + impl Write for RejectingWriter<'_> { + fn write_str(&mut self, value: &str) -> fmt::Result { + if value.contains(self.0) { + Err(fmt::Error) + } else { + Ok(()) + } + } + } + + let statement = all_dialects().verified_stmt("GRANT bypasser TO anon WITH SET FALSE"); + for rejected in [" WITH ", "SET", "FALSE"] { + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); + } +}