diff --git a/Cargo.toml b/Cargo.toml index b2160af94..9bfd7cbb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,8 @@ [package] name = "pgmold-sqlparser" -description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params, EXCLUDE, TEXT SEARCH, AGGREGATE, FOREIGN TABLE/FDW, PUBLICATION, SUBSCRIPTION, ALTER DOMAIN/TRIGGER/EXTENSION, CAST, CONVERSION, LANGUAGE, RULE, STATISTICS, ACCESS METHOD, EVENT TRIGGER, TRANSFORM, SECURITY LABEL, USER MAPPING, TABLESPACE, GRANT ON TYPE/DOMAIN, COMMENT ON TRIGGER/AGGREGATE/POLICY, ALTER TYPE OWNER/SCHEMA/ATTRIBUTE, ALTER DEFAULT PRIVILEGES)" -version = "0.62.0" +description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params, EXCLUDE, TEXT SEARCH, AGGREGATE, FOREIGN TABLE/FDW, PUBLICATION, SUBSCRIPTION, ALTER DOMAIN/TRIGGER/EXTENSION, CAST, CONVERSION, LANGUAGE, RULE, STATISTICS, ACCESS METHOD, EVENT TRIGGER, TRANSFORM, SECURITY LABEL, USER MAPPING, TABLESPACE, GRANT ON TYPE/DOMAIN, COMMENT ON TRIGGER/AGGREGATE/POLICY/CONSTRAINT/OPERATOR/RULE, ALTER TYPE OWNER/SCHEMA/ATTRIBUTE, ALTER DEFAULT PRIVILEGES)" +version = "0.63.0" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" diff --git a/changelog/0.63.0.md b/changelog/0.63.0.md new file mode 100644 index 000000000..3c8fbbcfd --- /dev/null +++ b/changelog/0.63.0.md @@ -0,0 +1,32 @@ + + +# pgmold-sqlparser 0.63.0 Changelog + +Fork-only release. Covers fork-side changes since 0.62.0; no upstream sync. + +**Breaking changes:** + +- PostgreSQL: `Statement::Comment` gains two fields, `operator_args: Option` (operand types for `COMMENT ON OPERATOR`, each side `Option` to model `NONE` for unary operators) and `on_domain: bool` (set when `COMMENT ON CONSTRAINT … ON DOMAIN ` is parsed). Existing destructures must be updated. + +**Other:** + +- PostgreSQL: `COMMENT ON CONSTRAINT name ON [DOMAIN] target IS …` is now parsed into `CommentObject::Constraint` with `table_name` carrying the relation/domain and `on_domain` distinguishing the two forms. +- PostgreSQL: `COMMENT ON OPERATOR name (left, right) IS …` is now parsed into `CommentObject::Operator` with `operator_args` carrying the operand signature; `NONE` is preserved as `None` for prefix/postfix unary operators. +- PostgreSQL: `COMMENT ON RULE name ON target IS …` is now parsed into `CommentObject::Rule` with `table_name` carrying the target relation. diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d56713e45..10caaeea8 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2467,6 +2467,8 @@ pub enum CommentObject { Collation, /// A table column. Column, + /// A table or domain constraint. + Constraint, /// A database. Database, /// A domain. @@ -2479,12 +2481,16 @@ pub enum CommentObject { Index, /// A materialized view. MaterializedView, + /// A user-defined operator. + Operator, /// A row-level security policy. Policy, /// A procedure. Procedure, /// A role. Role, + /// A query rewrite rule. + Rule, /// A schema. Schema, /// A sequence. @@ -2507,15 +2513,18 @@ impl CommentObject { CommentObject::Aggregate => "AGGREGATE", CommentObject::Collation => "COLLATION", CommentObject::Column => "COLUMN", + CommentObject::Constraint => "CONSTRAINT", CommentObject::Database => "DATABASE", CommentObject::Domain => "DOMAIN", CommentObject::Extension => "EXTENSION", CommentObject::Function => "FUNCTION", CommentObject::Index => "INDEX", CommentObject::MaterializedView => "MATERIALIZED VIEW", + CommentObject::Operator => "OPERATOR", CommentObject::Policy => "POLICY", CommentObject::Procedure => "PROCEDURE", CommentObject::Role => "ROLE", + CommentObject::Rule => "RULE", CommentObject::Schema => "SCHEMA", CommentObject::Sequence => "SEQUENCE", CommentObject::Table => "TABLE", @@ -2535,14 +2544,17 @@ impl CommentObject { Keyword::AGGREGATE => CommentObject::Aggregate, Keyword::COLLATION => CommentObject::Collation, Keyword::COLUMN => CommentObject::Column, + Keyword::CONSTRAINT => CommentObject::Constraint, Keyword::DATABASE => CommentObject::Database, Keyword::DOMAIN => CommentObject::Domain, Keyword::EXTENSION => CommentObject::Extension, Keyword::FUNCTION => CommentObject::Function, Keyword::INDEX => CommentObject::Index, + Keyword::OPERATOR => CommentObject::Operator, Keyword::POLICY => CommentObject::Policy, Keyword::PROCEDURE => CommentObject::Procedure, Keyword::ROLE => CommentObject::Role, + Keyword::RULE => CommentObject::Rule, Keyword::SCHEMA => CommentObject::Schema, Keyword::SEQUENCE => CommentObject::Sequence, Keyword::TABLE => CommentObject::Table, @@ -2561,6 +2573,38 @@ impl fmt::Display for CommentObject { } } +/// Operand types for `COMMENT ON OPERATOR name (left, right)`. +/// +/// Each side may be `NONE` for prefix or postfix unary operators, mirroring +/// the syntax allowed by `DROP OPERATOR` / `ALTER OPERATOR`. Both sides are +/// optional independently because PostgreSQL accepts unary operators in +/// either direction. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CommentOperatorArgs { + /// Left-hand operand type, or `None` for `NONE` (prefix operator). + pub left: Option, + /// Right-hand operand type, or `None` for `NONE` (postfix operator). + pub right: Option, +} + +impl fmt::Display for CommentOperatorArgs { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let write_side = |opt: &Option, f: &mut fmt::Formatter| -> fmt::Result { + match opt { + Some(dt) => write!(f, "{dt}"), + None => f.write_str("NONE"), + } + }; + f.write_str("(")?; + write_side(&self.left, f)?; + f.write_str(", ")?; + write_side(&self.right, f)?; + f.write_str(")") + } +} + #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] @@ -4461,10 +4505,21 @@ pub enum Statement { /// while `None` means no parameter list was provided. Used for /// `FUNCTION`, `PROCEDURE`, and `AGGREGATE` targets. arguments: Option>, - /// Partner table for objects scoped to a table, i.e. the - /// `ON ` tail in `COMMENT ON TRIGGER t ON tbl IS '…'` or - /// `COMMENT ON POLICY p ON tbl IS '…'`. + /// Operand signature for `COMMENT ON OPERATOR name (left, right)`. + /// Always `Some(_)` for `Operator`, `None` for every other variant. + /// Modeled separately from `arguments` because operator slots may be + /// `NONE` (unary operators), which `Vec` cannot express. + operator_args: Option, + /// Partner relation for objects scoped to a relation, i.e. the + /// `ON
` (or `ON DOMAIN `) tail in + /// `COMMENT ON TRIGGER t ON tbl IS '…'`, + /// `COMMENT ON POLICY p ON tbl IS '…'`, + /// `COMMENT ON RULE r ON tbl IS '…'`, or + /// `COMMENT ON CONSTRAINT c ON [DOMAIN] tbl IS '…'`. table_name: Option, + /// `true` when the relation tail used the `ON DOMAIN ` form. + /// Only meaningful for `Constraint`; always `false` otherwise. + on_domain: bool, /// Optional comment text (None to remove comment). comment: Option, /// An optional `IF EXISTS` clause. (Non-standard.) @@ -6252,7 +6307,9 @@ impl fmt::Display for Statement { object_type, object_name, arguments, + operator_args, table_name, + on_domain, comment, if_exists, } => { @@ -6264,8 +6321,12 @@ impl fmt::Display for Statement { if let Some(args) = arguments { write!(f, "({})", display_comma_separated(args))?; } + if let Some(operator_args) = operator_args { + write!(f, "{operator_args}")?; + } if let Some(table_name) = table_name { - write!(f, " ON {table_name}")?; + let prefix = if *on_domain { " ON DOMAIN " } else { " ON " }; + write!(f, "{prefix}{table_name}")?; } write!(f, " IS ")?; if let Some(c) = comment { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5478ad6dd..246e741f4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -917,7 +917,11 @@ impl<'a> Parser<'a> { }, None => return self.expected("comment object_type", token), }; - let object_name = self.parse_object_name(false)?; + let object_name = if object_type == CommentObject::Operator { + self.parse_operator_name()? + } else { + self.parse_object_name(false)? + }; let arguments = match object_type { CommentObject::Function | CommentObject::Procedure | CommentObject::Aggregate => { @@ -939,12 +943,28 @@ impl<'a> Parser<'a> { )); } - let table_name = match object_type { - CommentObject::Trigger | CommentObject::Policy => { + let operator_args = if object_type == CommentObject::Operator { + self.expect_token(&Token::LParen)?; + let left = self.parse_operator_arg_type_or_none()?; + self.expect_token(&Token::Comma)?; + let right = self.parse_operator_arg_type_or_none()?; + self.expect_token(&Token::RParen)?; + Some(CommentOperatorArgs { left, right }) + } else { + None + }; + + let (table_name, on_domain) = match object_type { + CommentObject::Trigger | CommentObject::Policy | CommentObject::Rule => { self.expect_keyword_is(Keyword::ON)?; - Some(self.parse_object_name(false)?) + (Some(self.parse_object_name(false)?), false) } - _ => None, + CommentObject::Constraint => { + self.expect_keyword_is(Keyword::ON)?; + let on_domain = self.parse_keyword(Keyword::DOMAIN); + (Some(self.parse_object_name(false)?), on_domain) + } + _ => (None, false), }; self.expect_keyword_is(Keyword::IS)?; @@ -957,7 +977,9 @@ impl<'a> Parser<'a> { object_type, object_name, arguments, + operator_args, table_name, + on_domain, comment, if_exists, }) @@ -8532,19 +8554,9 @@ impl<'a> Parser<'a> { fn parse_drop_operator_signature(&mut self) -> Result { let name = self.parse_operator_name()?; self.expect_token(&Token::LParen)?; - - // Parse left operand type (or NONE for prefix operators) - let left_type = if self.parse_keyword(Keyword::NONE) { - None - } else { - Some(self.parse_data_type()?) - }; - + let left_type = self.parse_operator_arg_type_or_none()?; self.expect_token(&Token::Comma)?; - - // Parse right operand type (always required) let right_type = self.parse_data_type()?; - self.expect_token(&Token::RParen)?; Ok(DropOperatorSignature { @@ -8554,6 +8566,16 @@ impl<'a> Parser<'a> { }) } + /// Parse one slot of a PostgreSQL operator signature: a `DataType` or the + /// keyword `NONE`. Used by `DROP OPERATOR` and `COMMENT ON OPERATOR`. + fn parse_operator_arg_type_or_none(&mut self) -> Result, ParserError> { + if self.parse_keyword(Keyword::NONE) { + Ok(None) + } else { + Ok(Some(self.parse_data_type()?)) + } + } + /// Parse a [Statement::DropOperatorFamily] /// /// [PostgreSQL Documentation](https://www.postgresql.org/docs/current/sql-dropopfamily.html) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 491af043d..cc6fdfc61 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15368,6 +15368,9 @@ fn parse_comments() { } // https://www.postgresql.org/docs/current/sql-comment.html + // CONSTRAINT, OPERATOR, RULE require object-specific tails handled in + // their own tests; this table only covers `COMMENT ON name IS '…'` + // shapes that share the simple object_name + comment structure. let object_types = [ ("COLLATION", CommentObject::Collation), ("COLUMN", CommentObject::Column), diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index c8bdb280a..8ab23d7f4 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -1115,7 +1115,9 @@ fn parse_drop_and_comment_collation_ast() { object_type: CommentObject::Collation, object_name: ObjectName::from(vec![Ident::new("test0")]), arguments: None, + operator_args: None, table_name: None, + on_domain: false, comment: Some("US English".to_string()), if_exists: false, } @@ -10560,6 +10562,8 @@ fn parse_comment_on_trigger() { object_name, table_name, arguments, + operator_args: _, + on_domain: _, comment, if_exists, } => { @@ -10587,6 +10591,8 @@ fn parse_comment_on_policy() { object_name, table_name, arguments, + operator_args: _, + on_domain: _, comment, if_exists, } => { @@ -10611,6 +10617,8 @@ fn parse_comment_on_aggregate() { object_name, table_name, arguments, + operator_args: _, + on_domain: _, comment, if_exists, } => { @@ -10648,6 +10656,8 @@ fn parse_comment_on_function_with_arg_types() { object_name, table_name, arguments, + operator_args: _, + on_domain: _, comment, if_exists, } => { @@ -10716,6 +10726,184 @@ fn parse_comment_on_aggregate_with_argname_and_variadic() { ); } +#[test] +fn parse_comment_on_constraint_on_table() { + match pg_and_generic() + .verified_stmt("COMMENT ON CONSTRAINT positive_total ON public.orders IS 'must be > 0'") + { + Statement::Comment { + object_type, + object_name, + table_name, + arguments, + operator_args, + on_domain, + comment, + if_exists, + } => { + assert_eq!(CommentObject::Constraint, object_type); + assert_eq!("positive_total", object_name.to_string()); + assert_eq!("public.orders", table_name.unwrap().to_string()); + assert!(!on_domain); + assert!(arguments.is_none()); + assert!(operator_args.is_none()); + assert_eq!(Some("must be > 0".to_string()), comment); + assert!(!if_exists); + } + _ => panic!("Expected COMMENT ON CONSTRAINT"), + } + + pg_and_generic().verified_stmt("COMMENT ON CONSTRAINT c ON tbl IS NULL"); + pg_and_generic().verified_stmt("COMMENT IF EXISTS ON CONSTRAINT c ON s.tbl IS 'note'"); +} + +#[test] +fn parse_comment_on_constraint_on_domain() { + match pg_and_generic() + .verified_stmt("COMMENT ON CONSTRAINT not_null_check ON DOMAIN public.email IS 'guard'") + { + Statement::Comment { + object_type, + object_name, + table_name, + on_domain, + comment, + .. + } => { + assert_eq!(CommentObject::Constraint, object_type); + assert_eq!("not_null_check", object_name.to_string()); + assert_eq!("public.email", table_name.unwrap().to_string()); + assert!(on_domain); + assert_eq!(Some("guard".to_string()), comment); + } + _ => panic!("Expected COMMENT ON CONSTRAINT ... ON DOMAIN"), + } + + pg_and_generic().verified_stmt("COMMENT ON CONSTRAINT c ON DOMAIN d IS NULL"); + pg_and_generic().verified_stmt("COMMENT ON CONSTRAINT c ON DOMAIN public.email IS 'qualified'"); +} + +#[test] +fn parse_comment_on_constraint_requires_relation_tail() { + let result = pg().parse_sql_statements("COMMENT ON CONSTRAINT c IS 'bad'"); + assert!( + result.is_err(), + "COMMENT ON CONSTRAINT without ON tail must error, got: {result:?}" + ); +} + +#[test] +fn parse_comment_on_operator_binary() { + match pg_and_generic() + .verified_stmt("COMMENT ON OPERATOR public.+(INTEGER, INTEGER) IS 'integer addition'") + { + Statement::Comment { + object_type, + object_name, + operator_args, + arguments, + table_name, + on_domain, + comment, + if_exists, + } => { + assert_eq!(CommentObject::Operator, object_type); + assert_eq!("public.+", object_name.to_string()); + let op_args = operator_args.expect("operator should carry argument signature"); + assert!(matches!(op_args.left, Some(DataType::Integer(_)))); + assert!(matches!(op_args.right, Some(DataType::Integer(_)))); + assert!(arguments.is_none()); + assert!(table_name.is_none()); + assert!(!on_domain); + assert_eq!(Some("integer addition".to_string()), comment); + assert!(!if_exists); + } + _ => panic!("Expected COMMENT ON OPERATOR"), + } +} + +#[test] +fn parse_comment_on_operator_prefix_left_none() { + match pg_and_generic().verified_stmt("COMMENT ON OPERATOR -(NONE, INTEGER) IS 'unary minus'") { + Statement::Comment { + object_type, + operator_args, + .. + } => { + assert_eq!(CommentObject::Operator, object_type); + let op_args = operator_args.expect("operator should carry argument signature"); + assert!(op_args.left.is_none()); + assert!(matches!(op_args.right, Some(DataType::Integer(_)))); + } + _ => panic!("Expected COMMENT ON OPERATOR"), + } +} + +#[test] +fn parse_comment_on_operator_postfix_right_none() { + match pg_and_generic().verified_stmt("COMMENT ON OPERATOR !(INTEGER, NONE) IS 'factorial'") { + Statement::Comment { + object_type, + operator_args, + .. + } => { + assert_eq!(CommentObject::Operator, object_type); + let op_args = operator_args.expect("operator should carry argument signature"); + assert!(matches!(op_args.left, Some(DataType::Integer(_)))); + assert!(op_args.right.is_none()); + } + _ => panic!("Expected COMMENT ON OPERATOR"), + } +} + +#[test] +fn parse_comment_on_operator_requires_argument_list() { + let result = pg().parse_sql_statements("COMMENT ON OPERATOR + IS 'bad'"); + assert!( + result.is_err(), + "COMMENT ON OPERATOR without (left, right) must error, got: {result:?}" + ); +} + +#[test] +fn parse_comment_on_rule() { + match pg_and_generic() + .verified_stmt("COMMENT ON RULE notify_me ON public.orders IS 'rewrite rule'") + { + Statement::Comment { + object_type, + object_name, + table_name, + arguments, + operator_args, + on_domain, + comment, + if_exists, + } => { + assert_eq!(CommentObject::Rule, object_type); + assert_eq!("notify_me", object_name.to_string()); + assert_eq!("public.orders", table_name.unwrap().to_string()); + assert!(!on_domain); + assert!(arguments.is_none()); + assert!(operator_args.is_none()); + assert_eq!(Some("rewrite rule".to_string()), comment); + assert!(!if_exists); + } + _ => panic!("Expected COMMENT ON RULE"), + } + + pg_and_generic().verified_stmt("COMMENT ON RULE r ON t IS NULL"); +} + +#[test] +fn parse_comment_on_rule_requires_relation_tail() { + let result = pg().parse_sql_statements("COMMENT ON RULE r IS 'bad'"); + assert!( + result.is_err(), + "COMMENT ON RULE without ON
tail must error, got: {result:?}" + ); +} + #[test] fn parse_alter_type_add_attribute() { let sql = "ALTER TYPE public.my_type ADD ATTRIBUTE new_attr INTEGER";