From 0f9f12dbd9566af98d6e0963d5dee0d9f83dcf82 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 2 Oct 2026 18:14:59 +0200 Subject: [PATCH 1/5] Add support for PostgreSQL `CREATE RULE` --- src/ast/ddl.rs | 149 +++++++++++++++++++++++++++++++++++- src/ast/mod.rs | 25 +++--- src/ast/spans.rs | 1 + src/keywords.rs | 1 + src/parser/mod.rs | 78 +++++++++++++++++++ tests/sqlparser_common.rs | 68 ++++++++++++++++ tests/sqlparser_postgres.rs | 10 +++ 7 files changed, 319 insertions(+), 13 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index e45c5f3cb7..fbbb0e3fe8 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -//! AST types specific to CREATE/ALTER variants of [`Statement`](crate::ast::Statement) +//! AST types specific to CREATE/ALTER variants of [`Statement`] //! (commonly referred to as Data Definition Language, or DDL) #[cfg(not(feature = "std"))] @@ -48,9 +48,10 @@ use crate::ast::{ FunctionSecurity, HiveDistributionStyle, HiveFormat, HiveIOFormat, HiveRowFormat, HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, OnCommit, OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, RefreshModeKind, - ResetConfig, RowAccessPolicy, SequenceOptions, Spanned, SqlOption, StorageLifecyclePolicy, - StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, TriggerExecBody, TriggerObject, - TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, WrappedCollection, + ResetConfig, RowAccessPolicy, SequenceOptions, Spanned, SqlOption, Statement, + StorageLifecyclePolicy, StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, + TriggerExecBody, TriggerObject, TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, + WrappedCollection, }; use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; use crate::keywords::Keyword; @@ -6041,6 +6042,146 @@ impl From for crate::ast::Statement { } } +/// CREATE RULE statement. +/// +/// ```sql +/// CREATE [ OR REPLACE ] RULE name AS ON event +/// TO table_name [ WHERE condition ] +/// DO [ ALSO | INSTEAD ] { NOTHING | command | ( command ; command ... ) } +/// ``` +/// +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrule.html) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateRule { + /// `true` when `OR REPLACE` was present. + pub or_replace: bool, + /// Name of the rule. + pub name: Ident, + /// Event the rule fires on (`SELECT`, `INSERT`, `UPDATE` or `DELETE`). + pub event: CreateRuleEvent, + /// Table the rule is defined on. + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + pub table_name: ObjectName, + /// Optional expression for the `WHERE` clause. + pub condition: Option, + /// Optional `ALSO` or `INSTEAD` keyword following `DO`. + pub do_kind: Option, + /// Action the rule performs. + pub action: CreateRuleAction, +} + +impl fmt::Display for CreateRule { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE {or_replace}RULE {name} AS ON {event} TO {table_name}", + or_replace = if self.or_replace { "OR REPLACE " } else { "" }, + name = self.name, + event = self.event, + table_name = self.table_name, + )?; + if let Some(condition) = &self.condition { + write!(f, " WHERE {condition}")?; + } + write!(f, " DO")?; + if let Some(do_kind) = &self.do_kind { + write!(f, " {do_kind}")?; + } + write!(f, " {}", self.action) + } +} + +/// Event that fires a rule (`ON` clause). +/// ```sql +/// AS ON [SELECT | INSERT | UPDATE | DELETE] +/// ``` +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrule.html) +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum CreateRuleEvent { + /// Fires on SELECT. + Select, + /// Fires on INSERT. + Insert, + /// Fires on UPDATE. + Update, + /// Fires on DELETE. + Delete, +} + +impl fmt::Display for CreateRuleEvent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CreateRuleEvent::Select => write!(f, "SELECT"), + CreateRuleEvent::Insert => write!(f, "INSERT"), + CreateRuleEvent::Update => write!(f, "UPDATE"), + CreateRuleEvent::Delete => write!(f, "DELETE"), + } + } +} + +/// Keyword following `DO` in a `CREATE RULE` statement. +/// ```sql +/// DO [ ALSO | INSTEAD ] +/// ``` +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrule.html) +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum CreateRuleDoKind { + /// The action runs in addition to the original statement. + Also, + /// The action runs instead of the original statement. + Instead, +} + +impl fmt::Display for CreateRuleDoKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CreateRuleDoKind::Also => write!(f, "ALSO"), + CreateRuleDoKind::Instead => write!(f, "INSTEAD"), + } + } +} + +/// Action of a `CREATE RULE` statement. +/// ```sql +/// { NOTHING | command | ( command ; command ... ) } +/// ``` +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrule.html) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum CreateRuleAction { + /// `NOTHING` + Nothing, + /// A single command, e.g. `DO INSTEAD SELECT * FROM t`. + Statement(Box), + /// A parenthesized, semicolon-separated list of commands. + Statements(Vec), +} + +impl fmt::Display for CreateRuleAction { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CreateRuleAction::Nothing => write!(f, "NOTHING"), + CreateRuleAction::Statement(statement) => write!(f, "{statement}"), + CreateRuleAction::Statements(statements) => { + write!(f, "({})", display_separated(statements, "; ")) + } + } + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateRule) -> Self { + crate::ast::Statement::CreateRule(v) + } +} + /// A `CREATE FOREIGN TABLE` statement. /// /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigntable.html) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 8e798524c9..05fb29178b 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -75,15 +75,16 @@ pub use self::ddl::{ CreateAggregateOption, CreateCollation, CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, CreateForeignTable, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateTable, CreateTextSearch, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, - DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, - DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, ForValues, - FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, IdentityProperty, - IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, - IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, - OperatorClassItem, OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, - Owner, Partition, PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, - ReplicaIdentity, SqliteTableOption, SqliteTableOptions, TagsColumnOption, TextSearchObjectType, + CreateRule, CreateRuleAction, CreateRuleDoKind, CreateRuleEvent, CreateTable, CreateTextSearch, + CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, + DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, + DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, + GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, + IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, + KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, OperatorClassItem, + OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, Owner, Partition, + PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, + SqliteTableOption, SqliteTableOptions, TagsColumnOption, TextSearchObjectType, TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, WithData, @@ -3825,6 +3826,11 @@ pub enum Statement { /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createpolicy.html) CreatePolicy(CreatePolicy), /// ```sql + /// CREATE RULE + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createrule.html) + CreateRule(CreateRule), + /// ```sql /// CREATE CONNECTOR /// ``` /// See [Hive](https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=27362034#LanguageManualDDL-CreateDataConnectorCreateConnector) @@ -5686,6 +5692,7 @@ impl fmt::Display for Statement { } Statement::CreateForeignTable(stmt) => write!(f, "{stmt}"), Statement::CreatePolicy(policy) => write!(f, "{policy}"), + Statement::CreateRule(rule) => write!(f, "{rule}"), Statement::CreateConnector(create_connector) => create_connector.fmt(f), Statement::CreateOperator(create_operator) => create_operator.fmt(f), Statement::CreateOperatorFamily(create_operator_family) => { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7638d7d6c0..f7e18fd478 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -487,6 +487,7 @@ impl Spanned for Statement { Statement::Unload { .. } => Span::empty(), Statement::OptimizeTable { .. } => Span::empty(), Statement::CreatePolicy { .. } => Span::empty(), + Statement::CreateRule { .. } => Span::empty(), Statement::AlterPolicy { .. } => Span::empty(), Statement::AlterConnector { .. } => Span::empty(), Statement::DropPolicy { .. } => Span::empty(), diff --git a/src/keywords.rs b/src/keywords.rs index 8a6c980cab..fa810e11b8 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -115,6 +115,7 @@ define_keywords!( ALL, ALLOCATE, ALLOWOVERWRITE, + ALSO, ALTER, ALWAYS, ANALYZE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3efb22a349..7ca140b34d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5457,6 +5457,8 @@ impl<'a> Parser<'a> { .map(Into::into) } else if self.parse_keyword(Keyword::POLICY) { self.parse_create_policy().map(Into::into) + } else if self.parse_keyword(Keyword::RULE) { + self.parse_create_rule(or_replace).map(Into::into) } else if self.parse_keyword(Keyword::EXTERNAL) { self.parse_create_external_table(or_replace).map(Into::into) } else if self.parse_keyword(Keyword::FUNCTION) { @@ -7448,6 +7450,82 @@ impl<'a> Parser<'a> { }) } + /// ```sql + /// CREATE [ OR REPLACE ] RULE name AS ON event + /// TO table_name [ WHERE condition ] + /// DO [ ALSO | INSTEAD ] { NOTHING | command | ( command ; command ... ) } + /// ``` + /// + /// [PostgreSQL Documentation](https://www.postgresql.org/docs/current/sql-createrule.html) + pub fn parse_create_rule(&mut self, or_replace: bool) -> Result { + let name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::AS)?; + self.expect_keyword_is(Keyword::ON)?; + let event = match self.expect_one_of_keywords(&[ + Keyword::SELECT, + Keyword::INSERT, + Keyword::UPDATE, + Keyword::DELETE, + ])? { + Keyword::SELECT => CreateRuleEvent::Select, + Keyword::INSERT => CreateRuleEvent::Insert, + Keyword::UPDATE => CreateRuleEvent::Update, + Keyword::DELETE => CreateRuleEvent::Delete, + unexpected_keyword => { + return Err(ParserError::ParserError(format!( + "Internal parser error: unexpected keyword `{unexpected_keyword}` in rule event" + ))) + } + }; + self.expect_keyword_is(Keyword::TO)?; + let table_name = self.parse_object_name(false)?; + + let condition = if self.parse_keyword(Keyword::WHERE) { + Some(self.parse_expr()?) + } else { + None + }; + + self.expect_keyword_is(Keyword::DO)?; + let do_kind = if self.parse_keyword(Keyword::ALSO) { + Some(CreateRuleDoKind::Also) + } else if self.parse_keyword(Keyword::INSTEAD) { + Some(CreateRuleDoKind::Instead) + } else { + None + }; + + let action = if self.parse_keyword(Keyword::NOTHING) { + CreateRuleAction::Nothing + } else if self.consume_token(&Token::LParen) { + let mut statements = Vec::new(); + loop { + while self.consume_token(&Token::SemiColon) {} + if self.consume_token(&Token::RParen) { + break; + } + statements.push(self.parse_statement()?); + if !self.consume_token(&Token::SemiColon) { + self.expect_token(&Token::RParen)?; + break; + } + } + CreateRuleAction::Statements(statements) + } else { + CreateRuleAction::Statement(Box::new(self.parse_statement()?)) + }; + + Ok(CreateRule { + or_replace, + name, + event, + table_name, + condition, + do_kind, + action, + }) + } + /// ```sql /// CREATE CONNECTOR [IF NOT EXISTS] connector_name /// [TYPE datasource_type] diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index f71869e820..82f7b3aa6e 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -20941,6 +20941,74 @@ fn parse_stage_table_factor() { ); } +#[test] +fn parse_create_rule() { + let sql = "CREATE RULE r AS ON DELETE TO docs DO INSTEAD NOTHING"; + match all_dialects().verified_stmt(sql) { + Statement::CreateRule(CreateRule { + or_replace, + name, + event, + table_name, + condition, + do_kind, + action, + }) => { + assert!(!or_replace); + assert_eq!(name.to_string(), "r"); + assert_eq!(event, CreateRuleEvent::Delete); + assert_eq!(table_name.to_string(), "docs"); + assert!(condition.is_none()); + assert_eq!(do_kind, Some(CreateRuleDoKind::Instead)); + assert_eq!(action, CreateRuleAction::Nothing); + } + _ => unreachable!("Expected CREATE RULE"), + } + + all_dialects().verified_stmt("CREATE OR REPLACE RULE r AS ON SELECT TO t DO ALSO NOTHING"); + all_dialects().verified_stmt("CREATE RULE r AS ON INSERT TO s1.t DO NOTHING"); + all_dialects().verified_stmt( + "CREATE RULE r AS ON UPDATE TO t WHERE quantity > 100 DO ALSO INSERT INTO audit (id) VALUES (1)", + ); + all_dialects().verified_stmt( + "CREATE RULE r AS ON DELETE TO t DO INSTEAD (UPDATE t2 SET deleted = 1 WHERE id = 1; DELETE FROM t3)", + ); + + // A trailing semicolon inside a parenthesized action list is accepted and + // normalized away. + all_dialects().one_statement_parses_to( + "CREATE RULE r AS ON DELETE TO t DO INSTEAD (DELETE FROM t2;)", + "CREATE RULE r AS ON DELETE TO t DO INSTEAD (DELETE FROM t2)", + ); + + // invalid event + assert_eq!( + all_dialects() + .parse_sql_statements("CREATE RULE r AS ON TRUNCATE TO t DO NOTHING") + .unwrap_err() + .to_string(), + "sql parser error: Expected: one of SELECT or INSERT or UPDATE or DELETE, found: TRUNCATE" + ); + + // missing DO clause + assert_eq!( + all_dialects() + .parse_sql_statements("CREATE RULE r AS ON DELETE TO t") + .unwrap_err() + .to_string(), + "sql parser error: Expected: DO, found: EOF" + ); + + // missing table name + assert_eq!( + all_dialects() + .parse_sql_statements("CREATE RULE r AS ON DELETE DO NOTHING") + .unwrap_err() + .to_string(), + "sql parser error: Expected: TO, found: DO" + ); +} + #[test] fn parse_alter_table_column_position() { let dialects = all_dialects_where(|d| d.supports_alter_column_position()); diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index aa9252125b..2aca24de0b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10845,6 +10845,16 @@ fn parse_geometric_typed_literal_error_propagates() { pg().parse_sql_statements("SELECT circle( FROM t") .unwrap_err(), ParserError::ParserError("Expected: a value, found: (".to_string()) +} + +#[test] +fn parse_create_rule() { + // Example from the PostgreSQL documentation. NOTIFY parsing is limited to + // dialects with `supports_listen_notify`. + pg().verified_stmt("CREATE RULE notify_me AS ON UPDATE TO mytable DO ALSO NOTIFY mytable"); + // OLD and NEW parse as plain identifiers inside rule conditions and actions. + pg_and_generic().verified_stmt( + "CREATE RULE r AS ON UPDATE TO t WHERE old.balance <> new.balance DO INSTEAD UPDATE shadow SET balance = new.balance WHERE id = old.id", ); } From b8edf250d18caaaabce5bb67ad66026380466189 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 10:49:24 +0200 Subject: [PATCH 2/5] Cover CREATE RULE boundaries and implement rule source spans --- src/ast/ddl.rs | 2 +- src/ast/spans.rs | 56 +++++++++----- src/parser/mod.rs | 37 +++++---- tests/sqlparser_common.rs | 154 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 32 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index fbbb0e3fe8..7c292a9345 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -6042,7 +6042,7 @@ impl From for crate::ast::Statement { } } -/// CREATE RULE statement. +/// A PostgreSQL rule with source spans derived from its identifiers, condition and actions. /// /// ```sql /// CREATE [ OR REPLACE ] RULE name AS ON event diff --git a/src/ast/spans.rs b/src/ast/spans.rs index f7e18fd478..f133c9f36c 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -32,22 +32,23 @@ use super::{ AlterIndexOperation, AlterTableOperation, Analyze, Array, Assignment, AssignmentTarget, AttachedToken, BeginEndStatements, CaseStatement, CloseCursor, ClusteredIndex, ColumnDef, ColumnOption, ColumnOptionDef, ConditionalStatementBlock, ConditionalStatements, - ConflictTarget, ConnectByKind, ConstraintCharacteristics, CopySource, CreateIndex, CreateTable, - CreateTableOptions, Cte, Delete, DoUpdate, ExceptSelectItem, ExcludeConstraintElement, - ExcludeSelectItem, Expr, ExprWithAlias, Fetch, ForValues, FromTable, Function, FunctionArg, - FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList, FunctionArguments, GroupByExpr, - HavingBound, IfStatement, IlikeSelectItem, InTarget, IndexColumn, Insert, Interpolate, - InterpolateExpr, Join, JoinConstraint, JoinOperator, JsonPath, JsonPathElem, LateralView, - LimitClause, MatchRecognizePattern, Measure, Merge, MergeAction, MergeClause, MergeInsertExpr, - MergeInsertKind, MergeUpdateExpr, MergeUpdateKind, NamedParenthesizedList, - NamedWindowDefinition, ObjectName, ObjectNamePart, Offset, OnConflict, OnConflictAction, - OnInsert, OpenStatement, OperateFunctionArg, OrderBy, OrderByExpr, OrderByKind, OutputClause, - Parens, Partition, PartitionBoundValue, PivotValueSource, ProjectionSelect, Query, - RaiseStatement, RaiseStatementValue, ReferentialAction, RenameSelectItem, ReplaceSelectElement, - ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, Setting, SqlOption, Statement, - Subscript, SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor, - TableObject, TableOptionsClustered, TableWithJoins, Update, UpdateTableFromKind, Use, Values, - ViewColumnDef, WhileStatement, WildcardAdditionalOptions, With, WithFill, + ConflictTarget, ConnectByKind, ConstraintCharacteristics, CopySource, CreateIndex, CreateRule, + CreateRuleAction, CreateTable, CreateTableOptions, Cte, Delete, DoUpdate, ExceptSelectItem, + ExcludeConstraintElement, ExcludeSelectItem, Expr, ExprWithAlias, Fetch, ForValues, FromTable, + Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList, + FunctionArguments, GroupByExpr, HavingBound, IfStatement, IlikeSelectItem, InTarget, + IndexColumn, Insert, Interpolate, InterpolateExpr, Join, JoinConstraint, JoinOperator, + JsonPath, JsonPathElem, LateralView, LimitClause, MatchRecognizePattern, Measure, Merge, + MergeAction, MergeClause, MergeInsertExpr, MergeInsertKind, MergeUpdateExpr, MergeUpdateKind, + NamedParenthesizedList, NamedWindowDefinition, ObjectName, ObjectNamePart, Offset, OnConflict, + OnConflictAction, OnInsert, OpenStatement, OperateFunctionArg, OrderBy, OrderByExpr, + OrderByKind, OutputClause, Parens, Partition, PartitionBoundValue, PivotValueSource, + ProjectionSelect, Query, RaiseStatement, RaiseStatementValue, ReferentialAction, + RenameSelectItem, ReplaceSelectElement, ReplaceSelectItem, Select, SelectInto, SelectItem, + SetExpr, Setting, SqlOption, Statement, Subscript, SymbolDefinition, TableAlias, + TableAliasColumnDef, TableConstraint, TableFactor, TableObject, TableOptionsClustered, + TableWithJoins, Update, UpdateTableFromKind, Use, Values, ViewColumnDef, WhileStatement, + WildcardAdditionalOptions, With, WithFill, }; /// Given an iterator of spans, return the [Span::union] of all spans. @@ -487,7 +488,7 @@ impl Spanned for Statement { Statement::Unload { .. } => Span::empty(), Statement::OptimizeTable { .. } => Span::empty(), Statement::CreatePolicy { .. } => Span::empty(), - Statement::CreateRule { .. } => Span::empty(), + Statement::CreateRule(rule) => rule.span(), Statement::AlterPolicy { .. } => Span::empty(), Statement::AlterConnector { .. } => Span::empty(), Statement::DropPolicy { .. } => Span::empty(), @@ -550,6 +551,27 @@ impl Spanned for Use { } } +impl Spanned for CreateRule { + fn span(&self) -> Span { + let action_span = match &self.action { + CreateRuleAction::Nothing => Span::empty(), + CreateRuleAction::Statement(statement) => statement.span(), + CreateRuleAction::Statements(statements) => { + union_spans(statements.iter().map(Spanned::span)) + } + }; + union_spans( + [ + self.name.span, + self.table_name.span(), + self.condition.as_ref().map_or(Span::empty(), Spanned::span), + action_span, + ] + .into_iter(), + ) + } +} + impl Spanned for CreateTable { fn span(&self) -> Span { let CreateTable { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 7ca140b34d..ae00d4526e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7461,22 +7461,31 @@ impl<'a> Parser<'a> { let name = self.parse_identifier()?; self.expect_keyword_is(Keyword::AS)?; self.expect_keyword_is(Keyword::ON)?; - let event = match self.expect_one_of_keywords(&[ - Keyword::SELECT, - Keyword::INSERT, - Keyword::UPDATE, - Keyword::DELETE, - ])? { - Keyword::SELECT => CreateRuleEvent::Select, - Keyword::INSERT => CreateRuleEvent::Insert, - Keyword::UPDATE => CreateRuleEvent::Update, - Keyword::DELETE => CreateRuleEvent::Delete, - unexpected_keyword => { - return Err(ParserError::ParserError(format!( - "Internal parser error: unexpected keyword `{unexpected_keyword}` in rule event" - ))) + let event = match &self.peek_token_ref().token { + Token::Word(Word { + keyword: Keyword::SELECT, + .. + }) => CreateRuleEvent::Select, + Token::Word(Word { + keyword: Keyword::INSERT, + .. + }) => CreateRuleEvent::Insert, + Token::Word(Word { + keyword: Keyword::UPDATE, + .. + }) => CreateRuleEvent::Update, + Token::Word(Word { + keyword: Keyword::DELETE, + .. + }) => CreateRuleEvent::Delete, + _ => { + return self.expected_ref( + "one of SELECT or INSERT or UPDATE or DELETE", + self.peek_token_ref(), + ) } }; + self.advance_token(); self.expect_keyword_is(Keyword::TO)?; let table_name = self.parse_object_name(false)?; diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 82f7b3aa6e..34d0f84df6 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -21597,3 +21597,157 @@ fn parse_qualified_literal_prefix_requires_adjacent_period() { dialects.tokenizes_to(sql, vec![token]); } } + +#[test] +fn parse_create_rule_action_boundaries() { + let dialects = all_dialects(); + let Statement::CreateRule(CreateRule { + action: CreateRuleAction::Statements(commands), + .. + }) = dialects.verified_stmt("CREATE RULE r AS ON INSERT TO t DO ()") + else { + panic!("expected a rule with a command list"); + }; + assert!(commands.is_empty()); + + let statements = dialects + .parse_sql_statements( + "CREATE RULE r AS ON INSERT TO t DO (;; INSERT INTO audit VALUES (1);; DELETE FROM pending;;); SELECT 9", + ) + .unwrap(); + assert_eq!( + statements.iter().map(ToString::to_string).collect::>(), + [ + "CREATE RULE r AS ON INSERT TO t DO (INSERT INTO audit VALUES (1); DELETE FROM pending)", + "SELECT 9", + ] + ); +} + +#[test] +fn parse_create_rule_invalid_components() { + let dialects = all_dialects(); + for (sql, expected) in [ + ("CREATE RULE", "Expected: identifier, found: EOF"), + ( + "CREATE RULE r ON UPDATE TO t DO NOTHING", + "Expected: AS, found: ON", + ), + ( + "CREATE RULE r AS UPDATE TO t DO NOTHING", + "Expected: ON, found: UPDATE", + ), + ( + "CREATE RULE r AS ON 1 TO t DO NOTHING", + "Expected: one of SELECT or INSERT or UPDATE or DELETE, found: 1", + ), + ( + "CREATE RULE r AS ON INSERT TO", + "Expected: identifier, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t WHERE (", + "Expected: an expression, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO", + "Expected: an SQL statement, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (BOGUS)", + "Expected: an SQL statement, found: BOGUS", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (SELECT 1 SELECT 2)", + "Expected: ), found: SELECT", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (SELECT 1", + "Expected: ), found: EOF", + ), + ] { + assert_eq!( + dialects.parse_sql_statements(sql), + Err(ParserError::ParserError(expected.to_string())), + "{sql}" + ); + } +} + +#[test] +fn parse_create_rule_spans() { + let dialects = all_dialects(); + for (sql, end) in [ + ( + "CREATE RULE r AS ON INSERT TO s.t DO NOTHING", + Location::new(1, 34), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t WHERE new.id > 0 DO NOTHING", + Location::new(1, 51), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t DO SELECT 1", + Location::new(1, 46), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t DO ()", + Location::new(1, 34), + ), + ( + r#"CREATE RULE r AS ON UPDATE TO s.t +WHERE old.id = new.id +DO ALSO ( + UPDATE audit SET id = new.id; + DELETE FROM archive WHERE id = old.id +)"#, + Location::new(5, 40), + ), + ] { + for dialect in &dialects.dialects { + let statements = Parser::parse_sql(dialect.as_ref(), sql).unwrap(); + assert_eq!( + statements[0].span(), + Span::new(Location::new(1, 13), end), + "{dialect:?} {sql}" + ); + } + } +} + +#[test] +fn display_create_rule_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 dialects = all_dialects(); + let statement = dialects.verified_stmt( + "CREATE OR REPLACE RULE r AS ON UPDATE TO t WHERE id > 0 DO ALSO (DELETE FROM audit)", + ); + for rejected in ["CREATE ", "r", "UPDATE", "t", ">", " DO", "ALSO", "(", ")"] { + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); + } + for (sql, rejected) in [ + ("CREATE RULE r AS ON INSERT TO t DO NOTHING", "NOTHING"), + ( + "CREATE RULE r AS ON DELETE TO t DO DELETE FROM audit", + "audit", + ), + ] { + let statement = dialects.verified_stmt(sql); + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); + } +} From eadc3016463c5ec0a2dcfa2df8b57de0f25f62c5 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 21 Oct 2026 09:52:02 +0200 Subject: [PATCH 3/5] Complete typed-literal regression assertion after rebase --- tests/sqlparser_postgres.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 2aca24de0b..17362b9fd5 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10845,6 +10845,7 @@ fn parse_geometric_typed_literal_error_propagates() { pg().parse_sql_statements("SELECT circle( FROM t") .unwrap_err(), ParserError::ParserError("Expected: a value, found: (".to_string()) + ); } #[test] From 14cc7afa77e342d39f999634ef9f087cac1789f3 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 00:29:22 +0200 Subject: [PATCH 4/5] Place regression tests beside related syntax cases --- tests/sqlparser_common.rs | 308 ++++++++++++++++++------------------ tests/sqlparser_postgres.rs | 22 +-- 2 files changed, 165 insertions(+), 165 deletions(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 34d0f84df6..7a422fe205 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -21009,6 +21009,160 @@ fn parse_create_rule() { ); } +#[test] +fn parse_create_rule_action_boundaries() { + let dialects = all_dialects(); + let Statement::CreateRule(CreateRule { + action: CreateRuleAction::Statements(commands), + .. + }) = dialects.verified_stmt("CREATE RULE r AS ON INSERT TO t DO ()") + else { + panic!("expected a rule with a command list"); + }; + assert!(commands.is_empty()); + + let statements = dialects + .parse_sql_statements( + "CREATE RULE r AS ON INSERT TO t DO (;; INSERT INTO audit VALUES (1);; DELETE FROM pending;;); SELECT 9", + ) + .unwrap(); + assert_eq!( + statements.iter().map(ToString::to_string).collect::>(), + [ + "CREATE RULE r AS ON INSERT TO t DO (INSERT INTO audit VALUES (1); DELETE FROM pending)", + "SELECT 9", + ] + ); +} + +#[test] +fn parse_create_rule_invalid_components() { + let dialects = all_dialects(); + for (sql, expected) in [ + ("CREATE RULE", "Expected: identifier, found: EOF"), + ( + "CREATE RULE r ON UPDATE TO t DO NOTHING", + "Expected: AS, found: ON", + ), + ( + "CREATE RULE r AS UPDATE TO t DO NOTHING", + "Expected: ON, found: UPDATE", + ), + ( + "CREATE RULE r AS ON 1 TO t DO NOTHING", + "Expected: one of SELECT or INSERT or UPDATE or DELETE, found: 1", + ), + ( + "CREATE RULE r AS ON INSERT TO", + "Expected: identifier, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t WHERE (", + "Expected: an expression, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO", + "Expected: an SQL statement, found: EOF", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (BOGUS)", + "Expected: an SQL statement, found: BOGUS", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (SELECT 1 SELECT 2)", + "Expected: ), found: SELECT", + ), + ( + "CREATE RULE r AS ON INSERT TO t DO (SELECT 1", + "Expected: ), found: EOF", + ), + ] { + assert_eq!( + dialects.parse_sql_statements(sql), + Err(ParserError::ParserError(expected.to_string())), + "{sql}" + ); + } +} + +#[test] +fn parse_create_rule_spans() { + let dialects = all_dialects(); + for (sql, end) in [ + ( + "CREATE RULE r AS ON INSERT TO s.t DO NOTHING", + Location::new(1, 34), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t WHERE new.id > 0 DO NOTHING", + Location::new(1, 51), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t DO SELECT 1", + Location::new(1, 46), + ), + ( + "CREATE RULE r AS ON INSERT TO s.t DO ()", + Location::new(1, 34), + ), + ( + r#"CREATE RULE r AS ON UPDATE TO s.t +WHERE old.id = new.id +DO ALSO ( + UPDATE audit SET id = new.id; + DELETE FROM archive WHERE id = old.id +)"#, + Location::new(5, 40), + ), + ] { + for dialect in &dialects.dialects { + let statements = Parser::parse_sql(dialect.as_ref(), sql).unwrap(); + assert_eq!( + statements[0].span(), + Span::new(Location::new(1, 13), end), + "{dialect:?} {sql}" + ); + } + } +} + +#[test] +fn display_create_rule_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 dialects = all_dialects(); + let statement = dialects.verified_stmt( + "CREATE OR REPLACE RULE r AS ON UPDATE TO t WHERE id > 0 DO ALSO (DELETE FROM audit)", + ); + for rejected in ["CREATE ", "r", "UPDATE", "t", ">", " DO", "ALSO", "(", ")"] { + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); + } + for (sql, rejected) in [ + ("CREATE RULE r AS ON INSERT TO t DO NOTHING", "NOTHING"), + ( + "CREATE RULE r AS ON DELETE TO t DO DELETE FROM audit", + "audit", + ), + ] { + let statement = dialects.verified_stmt(sql); + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); + } +} + #[test] fn parse_alter_table_column_position() { let dialects = all_dialects_where(|d| d.supports_alter_column_position()); @@ -21597,157 +21751,3 @@ fn parse_qualified_literal_prefix_requires_adjacent_period() { dialects.tokenizes_to(sql, vec![token]); } } - -#[test] -fn parse_create_rule_action_boundaries() { - let dialects = all_dialects(); - let Statement::CreateRule(CreateRule { - action: CreateRuleAction::Statements(commands), - .. - }) = dialects.verified_stmt("CREATE RULE r AS ON INSERT TO t DO ()") - else { - panic!("expected a rule with a command list"); - }; - assert!(commands.is_empty()); - - let statements = dialects - .parse_sql_statements( - "CREATE RULE r AS ON INSERT TO t DO (;; INSERT INTO audit VALUES (1);; DELETE FROM pending;;); SELECT 9", - ) - .unwrap(); - assert_eq!( - statements.iter().map(ToString::to_string).collect::>(), - [ - "CREATE RULE r AS ON INSERT TO t DO (INSERT INTO audit VALUES (1); DELETE FROM pending)", - "SELECT 9", - ] - ); -} - -#[test] -fn parse_create_rule_invalid_components() { - let dialects = all_dialects(); - for (sql, expected) in [ - ("CREATE RULE", "Expected: identifier, found: EOF"), - ( - "CREATE RULE r ON UPDATE TO t DO NOTHING", - "Expected: AS, found: ON", - ), - ( - "CREATE RULE r AS UPDATE TO t DO NOTHING", - "Expected: ON, found: UPDATE", - ), - ( - "CREATE RULE r AS ON 1 TO t DO NOTHING", - "Expected: one of SELECT or INSERT or UPDATE or DELETE, found: 1", - ), - ( - "CREATE RULE r AS ON INSERT TO", - "Expected: identifier, found: EOF", - ), - ( - "CREATE RULE r AS ON INSERT TO t WHERE (", - "Expected: an expression, found: EOF", - ), - ( - "CREATE RULE r AS ON INSERT TO t DO", - "Expected: an SQL statement, found: EOF", - ), - ( - "CREATE RULE r AS ON INSERT TO t DO (BOGUS)", - "Expected: an SQL statement, found: BOGUS", - ), - ( - "CREATE RULE r AS ON INSERT TO t DO (SELECT 1 SELECT 2)", - "Expected: ), found: SELECT", - ), - ( - "CREATE RULE r AS ON INSERT TO t DO (SELECT 1", - "Expected: ), found: EOF", - ), - ] { - assert_eq!( - dialects.parse_sql_statements(sql), - Err(ParserError::ParserError(expected.to_string())), - "{sql}" - ); - } -} - -#[test] -fn parse_create_rule_spans() { - let dialects = all_dialects(); - for (sql, end) in [ - ( - "CREATE RULE r AS ON INSERT TO s.t DO NOTHING", - Location::new(1, 34), - ), - ( - "CREATE RULE r AS ON INSERT TO s.t WHERE new.id > 0 DO NOTHING", - Location::new(1, 51), - ), - ( - "CREATE RULE r AS ON INSERT TO s.t DO SELECT 1", - Location::new(1, 46), - ), - ( - "CREATE RULE r AS ON INSERT TO s.t DO ()", - Location::new(1, 34), - ), - ( - r#"CREATE RULE r AS ON UPDATE TO s.t -WHERE old.id = new.id -DO ALSO ( - UPDATE audit SET id = new.id; - DELETE FROM archive WHERE id = old.id -)"#, - Location::new(5, 40), - ), - ] { - for dialect in &dialects.dialects { - let statements = Parser::parse_sql(dialect.as_ref(), sql).unwrap(); - assert_eq!( - statements[0].span(), - Span::new(Location::new(1, 13), end), - "{dialect:?} {sql}" - ); - } - } -} - -#[test] -fn display_create_rule_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 dialects = all_dialects(); - let statement = dialects.verified_stmt( - "CREATE OR REPLACE RULE r AS ON UPDATE TO t WHERE id > 0 DO ALSO (DELETE FROM audit)", - ); - for rejected in ["CREATE ", "r", "UPDATE", "t", ">", " DO", "ALSO", "(", ")"] { - let mut writer = RejectingWriter(rejected); - assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); - } - for (sql, rejected) in [ - ("CREATE RULE r AS ON INSERT TO t DO NOTHING", "NOTHING"), - ( - "CREATE RULE r AS ON DELETE TO t DO DELETE FROM audit", - "audit", - ), - ] { - let statement = dialects.verified_stmt(sql); - let mut writer = RejectingWriter(rejected); - assert_eq!(write!(writer, "{statement}"), Err(fmt::Error)); - } -} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 17362b9fd5..6c523939ca 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5873,6 +5873,17 @@ fn parse_drop_procedure() { ); } +#[test] +fn parse_create_rule() { + // Example from the PostgreSQL documentation. NOTIFY parsing is limited to + // dialects with `supports_listen_notify`. + pg().verified_stmt("CREATE RULE notify_me AS ON UPDATE TO mytable DO ALSO NOTIFY mytable"); + // OLD and NEW parse as plain identifiers inside rule conditions and actions. + pg_and_generic().verified_stmt( + "CREATE RULE r AS ON UPDATE TO t WHERE old.balance <> new.balance DO INSTEAD UPDATE shadow SET balance = new.balance WHERE id = old.id", + ); +} + #[test] fn parse_dollar_quoted_string() { let sql = "SELECT $$hello$$, $tag_name$world$tag_name$, $$Foo$Bar$$, $$Foo$Bar$$col_name, $$$$, $tag_name$$tag_name$"; @@ -10848,17 +10859,6 @@ fn parse_geometric_typed_literal_error_propagates() { ); } -#[test] -fn parse_create_rule() { - // Example from the PostgreSQL documentation. NOTIFY parsing is limited to - // dialects with `supports_listen_notify`. - pg().verified_stmt("CREATE RULE notify_me AS ON UPDATE TO mytable DO ALSO NOTIFY mytable"); - // OLD and NEW parse as plain identifiers inside rule conditions and actions. - pg_and_generic().verified_stmt( - "CREATE RULE r AS ON UPDATE TO t WHERE old.balance <> new.balance DO INSTEAD UPDATE shadow SET balance = new.balance WHERE id = old.id", - ); -} - #[test] fn parse_create_aggregate_rejects_output_arguments_and_defaults() { for (args, expected) in [ From 143f0e4d72bc0f514f52c8b56139f2771dfd21e4 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 13:43:18 +0200 Subject: [PATCH 5/5] Add fuzz seeds for pg create rule --- fuzz/fuzz_seeds/postgres_2433_create_rule.sql | 5 +++++ fuzz/fuzz_seeds/postgres_2433_create_rule_notify.sql | 1 + 2 files changed, 6 insertions(+) create mode 100644 fuzz/fuzz_seeds/postgres_2433_create_rule.sql create mode 100644 fuzz/fuzz_seeds/postgres_2433_create_rule_notify.sql diff --git a/fuzz/fuzz_seeds/postgres_2433_create_rule.sql b/fuzz/fuzz_seeds/postgres_2433_create_rule.sql new file mode 100644 index 0000000000..5e39a4abce --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_2433_create_rule.sql @@ -0,0 +1,5 @@ +CREATE RULE r AS ON DELETE TO docs DO INSTEAD NOTHING; +CREATE OR REPLACE RULE r AS ON SELECT TO t DO ALSO NOTHING; +CREATE RULE r AS ON INSERT TO s1.t DO NOTHING; +CREATE RULE r AS ON UPDATE TO t WHERE quantity > 100 DO ALSO INSERT INTO audit (id) VALUES (1); +CREATE RULE r AS ON DELETE TO t DO INSTEAD (UPDATE t2 SET deleted = 1 WHERE id = 1; DELETE FROM t3) diff --git a/fuzz/fuzz_seeds/postgres_2433_create_rule_notify.sql b/fuzz/fuzz_seeds/postgres_2433_create_rule_notify.sql new file mode 100644 index 0000000000..502d80a994 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_2433_create_rule_notify.sql @@ -0,0 +1 @@ +CREATE RULE notify_me AS ON UPDATE TO mytable DO ALSO NOTIFY mytable