From bd9e0a94e63a1e0609880a60a20c08cac73090cc Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 8 Dec 2025 15:02:57 +0900 Subject: [PATCH 01/30] Add PostgreSQL PARTITION OF syntax support (#2042). --- src/ast/ddl.rs | 90 ++++++++++++- src/ast/helpers/stmt_create_table.rs | 24 +++- src/ast/mod.rs | 17 +-- src/ast/spans.rs | 2 + src/keywords.rs | 2 + src/parser/mod.rs | 72 ++++++++++ tests/sqlparser_duckdb.rs | 2 + tests/sqlparser_mssql.rs | 4 + tests/sqlparser_postgres.rs | 190 +++++++++++++++++++++++++++ 9 files changed, 392 insertions(+), 11 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 3516c64a1b..20d6db156a 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -2688,6 +2688,14 @@ pub struct CreateTable { /// /// pub inherits: Option>, + /// PostgreSQL `PARTITION OF` clause to create a partition of a parent table. + /// Contains the parent table name. + /// + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + pub partition_of: Option, + /// PostgreSQL partition bound specification for PARTITION OF. + /// + pub for_values: Option, /// SQLite "STRICT" clause. /// if the "STRICT" table-option keyword is added to the end, after the closing ")", /// then strict typing rules apply to that table. @@ -2783,6 +2791,9 @@ impl fmt::Display for CreateTable { dynamic = if self.dynamic { "DYNAMIC " } else { "" }, name = self.name, )?; + if let Some(partition_of) = &self.partition_of { + write!(f, " PARTITION OF {partition_of}")?; + } if let Some(on_cluster) = &self.on_cluster { write!(f, " ON CLUSTER {on_cluster}")?; } @@ -2797,12 +2808,19 @@ impl fmt::Display for CreateTable { Indent(DisplayCommaSeparated(&self.constraints)).fmt(f)?; NewLine.fmt(f)?; f.write_str(")")?; - } else if self.query.is_none() && self.like.is_none() && self.clone.is_none() { + } else if self.query.is_none() + && self.like.is_none() + && self.clone.is_none() + && self.partition_of.is_none() + { // PostgreSQL allows `CREATE TABLE t ();`, but requires empty parens f.write_str(" ()")?; } else if let Some(CreateTableLikeKind::Parenthesized(like_in_columns_list)) = &self.like { write!(f, " ({like_in_columns_list})")?; } + if let Some(for_values) = &self.for_values { + write!(f, " {for_values}")?; + } // Hive table comment should be after column definitions, please refer to: // [Hive](https://cwiki.apache.org/confluence/display/Hive/LanguageManual+DDL#LanguageManualDDL-CreateTable) @@ -3044,6 +3062,76 @@ impl fmt::Display for CreateTable { } } +/// PostgreSQL partition bound specification for PARTITION OF. +/// +/// Specifies partition bounds for a child partition table. +/// +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.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 ForValues { + /// `FOR VALUES IN (expr, ...)` + In(Vec), + /// `FOR VALUES FROM (expr|MINVALUE|MAXVALUE, ...) TO (expr|MINVALUE|MAXVALUE, ...)` + From { + from: Vec, + to: Vec, + }, + /// `FOR VALUES WITH (MODULUS n, REMAINDER r)` + With { modulus: u64, remainder: u64 }, + /// `DEFAULT` + Default, +} + +impl fmt::Display for ForValues { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ForValues::In(values) => { + write!(f, "FOR VALUES IN ({})", display_comma_separated(values)) + } + ForValues::From { from, to } => { + write!( + f, + "FOR VALUES FROM ({}) TO ({})", + display_comma_separated(from), + display_comma_separated(to) + ) + } + ForValues::With { modulus, remainder } => { + write!( + f, + "FOR VALUES WITH (MODULUS {modulus}, REMAINDER {remainder})" + ) + } + ForValues::Default => write!(f, "DEFAULT"), + } + } +} + +/// A value in a partition bound specification. +/// +/// Used in RANGE partition bounds where values can be expressions, +/// MINVALUE (negative infinity), or MAXVALUE (positive infinity). +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum PartitionBoundValue { + Expr(Expr), + MinValue, + MaxValue, +} + +impl fmt::Display for PartitionBoundValue { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PartitionBoundValue::Expr(expr) => write!(f, "{expr}"), + PartitionBoundValue::MinValue => write!(f, "MINVALUE"), + PartitionBoundValue::MaxValue => write!(f, "MAXVALUE"), + } + } +} + #[derive(Debug, 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/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index fe950c909c..62dbbbcba0 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs @@ -26,8 +26,8 @@ use sqlparser_derive::{Visit, VisitMut}; use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTable, CreateTableLikeKind, CreateTableOptions, Expr, - FileFormat, HiveDistributionStyle, HiveFormat, Ident, InitializeKind, ObjectName, OnCommit, - OneOrManyWithParens, Query, RefreshModeKind, RowAccessPolicy, Statement, + FileFormat, ForValues, HiveDistributionStyle, HiveFormat, Ident, InitializeKind, ObjectName, + OnCommit, OneOrManyWithParens, Query, RefreshModeKind, RowAccessPolicy, Statement, StorageSerializationPolicy, TableConstraint, TableVersion, Tag, WrappedCollection, }; @@ -94,6 +94,8 @@ pub struct CreateTableBuilder { pub cluster_by: Option>>, pub clustered_by: Option, pub inherits: Option>, + pub partition_of: Option, + pub for_values: Option, pub strict: bool, pub copy_grants: bool, pub enable_schema_evolution: Option, @@ -150,6 +152,8 @@ impl CreateTableBuilder { cluster_by: None, clustered_by: None, inherits: None, + partition_of: None, + for_values: None, strict: false, copy_grants: false, enable_schema_evolution: None, @@ -317,6 +321,16 @@ impl CreateTableBuilder { self } + pub fn partition_of(mut self, partition_of: Option) -> Self { + self.partition_of = partition_of; + self + } + + pub fn for_values(mut self, for_values: Option) -> Self { + self.for_values = for_values; + self + } + pub fn strict(mut self, strict: bool) -> Self { self.strict = strict; self @@ -463,6 +477,8 @@ impl CreateTableBuilder { cluster_by: self.cluster_by, clustered_by: self.clustered_by, inherits: self.inherits, + partition_of: self.partition_of, + for_values: self.for_values, strict: self.strict, copy_grants: self.copy_grants, enable_schema_evolution: self.enable_schema_evolution, @@ -527,6 +543,8 @@ impl TryFrom for CreateTableBuilder { cluster_by, clustered_by, inherits, + partition_of, + for_values, strict, copy_grants, enable_schema_evolution, @@ -577,6 +595,8 @@ impl TryFrom for CreateTableBuilder { cluster_by, clustered_by, inherits, + partition_of, + for_values, strict, iceberg, copy_grants, diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 6cb4c33605..9aeb790a5e 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -68,14 +68,15 @@ pub use self::ddl::{ CreateExtension, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, - DropOperatorSignature, DropTrigger, GeneratedAs, GeneratedExpressionMode, IdentityParameters, - IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, - IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, - OperatorArgTypes, OperatorClassItem, OperatorOption, OperatorPurpose, Owner, Partition, - ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, TagsColumnOption, - TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, - UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, - UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, + DropOperatorSignature, DropTrigger, ForValues, GeneratedAs, GeneratedExpressionMode, + IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, + IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, + NullsDistinctOption, OperatorArgTypes, OperatorClassItem, OperatorOption, OperatorPurpose, + Owner, Partition, PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, + ReplicaIdentity, TagsColumnOption, TriggerObjectKind, Truncate, + UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, + UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, + UserDefinedTypeStorage, ViewColumnDef, }; pub use self::dml::{ Delete, Insert, Merge, MergeAction, MergeClause, MergeClauseKind, MergeInsertExpr, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d63ed62b4c..ea4e1e9ae2 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -553,6 +553,8 @@ impl Spanned for CreateTable { cluster_by: _, // todo, BigQuery specific clustered_by: _, // todo, Hive specific inherits: _, // todo, PostgreSQL specific + partition_of: _, // todo, PostgreSQL specific + for_values: _, // todo, PostgreSQL specific strict: _, // bool copy_grants: _, // bool enable_schema_evolution: _, // bool diff --git a/src/keywords.rs b/src/keywords.rs index f06842ec6a..87c77379c2 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -637,6 +637,7 @@ define_keywords!( MODIFIES, MODIFY, MODULE, + MODULUS, MONITOR, MONTH, MONTHS, @@ -837,6 +838,7 @@ define_keywords!( RELAY, RELEASE, RELEASES, + REMAINDER, REMOTE, REMOVE, REMOVEQUOTES, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index c499829cdf..bf0dc49b6f 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7833,6 +7833,15 @@ impl<'a> Parser<'a> { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let table_name = self.parse_object_name(allow_unquoted_hyphen)?; + // PostgreSQL PARTITION OF for child partition tables + let partition_of = if dialect_of!(self is PostgreSqlDialect | GenericDialect) + && self.parse_keywords(&[Keyword::PARTITION, Keyword::OF]) + { + Some(self.parse_object_name(allow_unquoted_hyphen)?) + } else { + None + }; + // Clickhouse has `ON CLUSTER 'cluster'` syntax for DDLs let on_cluster = self.parse_optional_on_cluster()?; @@ -7857,6 +7866,13 @@ impl<'a> Parser<'a> { None }; + // PostgreSQL PARTITION OF: partition bound specification + let for_values = if partition_of.is_some() { + Some(self.parse_partition_for_values()?) + } else { + None + }; + // SQLite supports `WITHOUT ROWID` at the end of `CREATE TABLE` let without_rowid = self.parse_keywords(&[Keyword::WITHOUT, Keyword::ROWID]); @@ -7934,6 +7950,8 @@ impl<'a> Parser<'a> { .partition_by(create_table_config.partition_by) .cluster_by(create_table_config.cluster_by) .inherits(create_table_config.inherits) + .partition_of(partition_of) + .for_values(for_values) .table_options(create_table_config.table_options) .primary_key(primary_key) .strict(strict) @@ -7993,6 +8011,60 @@ impl<'a> Parser<'a> { } } + /// Parse PostgreSQL partition bound specification for PARTITION OF. + /// + /// Parses: `FOR VALUES partition_bound_spec | DEFAULT` + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createtable.html) + fn parse_partition_for_values(&mut self) -> Result { + if self.parse_keyword(Keyword::DEFAULT) { + return Ok(ForValues::Default); + } + + self.expect_keywords(&[Keyword::FOR, Keyword::VALUES])?; + + if self.parse_keyword(Keyword::IN) { + // FOR VALUES IN (expr, ...) + self.expect_token(&Token::LParen)?; + let values = self.parse_comma_separated(Parser::parse_expr)?; + self.expect_token(&Token::RParen)?; + Ok(ForValues::In(values)) + } else if self.parse_keyword(Keyword::FROM) { + // FOR VALUES FROM (...) TO (...) + self.expect_token(&Token::LParen)?; + let from = self.parse_comma_separated(Parser::parse_partition_bound_value)?; + self.expect_token(&Token::RParen)?; + self.expect_keyword(Keyword::TO)?; + self.expect_token(&Token::LParen)?; + let to = self.parse_comma_separated(Parser::parse_partition_bound_value)?; + self.expect_token(&Token::RParen)?; + Ok(ForValues::From { from, to }) + } else if self.parse_keyword(Keyword::WITH) { + // FOR VALUES WITH (MODULUS n, REMAINDER r) + self.expect_token(&Token::LParen)?; + self.expect_keyword(Keyword::MODULUS)?; + let modulus = self.parse_literal_uint()?; + self.expect_token(&Token::Comma)?; + self.expect_keyword(Keyword::REMAINDER)?; + let remainder = self.parse_literal_uint()?; + self.expect_token(&Token::RParen)?; + Ok(ForValues::With { modulus, remainder }) + } else { + self.expected("IN, FROM, or WITH after FOR VALUES", self.peek_token()) + } + } + + /// Parse a single partition bound value (MINVALUE, MAXVALUE, or expression). + fn parse_partition_bound_value(&mut self) -> Result { + if self.parse_keyword(Keyword::MINVALUE) { + Ok(PartitionBoundValue::MinValue) + } else if self.parse_keyword(Keyword::MAXVALUE) { + Ok(PartitionBoundValue::MaxValue) + } else { + Ok(PartitionBoundValue::Expr(self.parse_expr()?)) + } + } + /// Parse configuration like inheritance, partitioning, clustering information during the table creation. /// /// [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#syntax_2) diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index 73a1afe260..4a2f29e151 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -755,6 +755,8 @@ fn test_duckdb_union_datatype() { cluster_by: Default::default(), clustered_by: Default::default(), inherits: Default::default(), + partition_of: Default::default(), + for_values: Default::default(), strict: Default::default(), copy_grants: Default::default(), enable_schema_evolution: Default::default(), diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 37e8e962f3..cce3ccef53 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -1897,6 +1897,8 @@ fn parse_create_table_with_valid_options() { cluster_by: None, clustered_by: None, inherits: None, + partition_of: None, + for_values: None, strict: false, iceberg: false, copy_grants: false, @@ -2064,6 +2066,8 @@ fn parse_create_table_with_identity_column() { cluster_by: None, clustered_by: None, inherits: None, + partition_of: None, + for_values: None, strict: false, copy_grants: false, enable_schema_evolution: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 11512cf803..dc4dcf5257 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6098,6 +6098,8 @@ fn parse_trigger_related_functions() { cluster_by: None, clustered_by: None, inherits: None, + partition_of: None, + for_values: None, strict: false, copy_grants: false, enable_schema_evolution: None, @@ -7492,3 +7494,191 @@ fn parse_create_operator_class() { ) .is_err()); } + +#[test] +fn parse_create_table_partition_of_range() { + // RANGE partition with FROM ... TO + let sql = "CREATE TABLE measurement_y2006m02 PARTITION OF measurement FOR VALUES FROM ('2006-02-01') TO ('2006-03-01')"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("measurement_y2006m02", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("measurement")])), + create_table.partition_of + ); + match create_table.for_values { + Some(ForValues::From { from, to }) => { + assert_eq!(1, from.len()); + assert_eq!(1, to.len()); + match &from[0] { + PartitionBoundValue::Expr(Expr::Value(v)) => { + assert_eq!("'2006-02-01'", v.to_string()); + } + _ => panic!("Expected Expr value in from"), + } + match &to[0] { + PartitionBoundValue::Expr(Expr::Value(v)) => { + assert_eq!("'2006-03-01'", v.to_string()); + } + _ => panic!("Expected Expr value in to"), + } + } + _ => panic!("Expected ForValues::From"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_range_with_minvalue_maxvalue() { + // RANGE partition with MINVALUE/MAXVALUE + let sql = + "CREATE TABLE orders_old PARTITION OF orders FOR VALUES FROM (MINVALUE) TO ('2020-01-01')"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("orders_old", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("orders")])), + create_table.partition_of + ); + match create_table.for_values { + Some(ForValues::From { from, to }) => { + assert_eq!(PartitionBoundValue::MinValue, from[0]); + match &to[0] { + PartitionBoundValue::Expr(Expr::Value(v)) => { + assert_eq!("'2020-01-01'", v.to_string()); + } + _ => panic!("Expected Expr value in to"), + } + } + _ => panic!("Expected ForValues::From"), + } + } + _ => panic!("Expected CreateTable"), + } + + // With MAXVALUE + let sql = + "CREATE TABLE orders_new PARTITION OF orders FOR VALUES FROM ('2024-01-01') TO (MAXVALUE)"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => match create_table.for_values { + Some(ForValues::From { from, to }) => { + match &from[0] { + PartitionBoundValue::Expr(Expr::Value(v)) => { + assert_eq!("'2024-01-01'", v.to_string()); + } + _ => panic!("Expected Expr value in from"), + } + assert_eq!(PartitionBoundValue::MaxValue, to[0]); + } + _ => panic!("Expected ForValues::From"), + }, + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_list() { + // LIST partition + let sql = "CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('US', 'CA', 'MX')"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("orders_us", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("orders")])), + create_table.partition_of + ); + match create_table.for_values { + Some(ForValues::In(values)) => { + assert_eq!(3, values.len()); + } + _ => panic!("Expected ForValues::In"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_hash() { + // HASH partition + let sql = "CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 0)"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("orders_p0", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("orders")])), + create_table.partition_of + ); + match create_table.for_values { + Some(ForValues::With { modulus, remainder }) => { + assert_eq!(4, modulus); + assert_eq!(0, remainder); + } + _ => panic!("Expected ForValues::With"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_default() { + // DEFAULT partition + let sql = "CREATE TABLE orders_default PARTITION OF orders DEFAULT"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("orders_default", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("orders")])), + create_table.partition_of + ); + assert_eq!(Some(ForValues::Default), create_table.for_values); + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_multicolumn_range() { + // Multi-column RANGE partition + let sql = "CREATE TABLE sales_2023_q1 PARTITION OF sales FOR VALUES FROM ('2023-01-01', 1) TO ('2023-04-01', 1)"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("sales_2023_q1", create_table.name.to_string()); + match create_table.for_values { + Some(ForValues::From { from, to }) => { + assert_eq!(2, from.len()); + assert_eq!(2, to.len()); + } + _ => panic!("Expected ForValues::From"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_create_table_partition_of_with_constraint() { + // With table constraint (not column constraint which has different syntax in PARTITION OF) + let sql = "CREATE TABLE orders_2023 PARTITION OF orders (\ +CONSTRAINT check_date CHECK (order_date >= '2023-01-01')\ +) FOR VALUES FROM ('2023-01-01') TO ('2024-01-01')"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!("orders_2023", create_table.name.to_string()); + assert_eq!( + Some(ObjectName::from(vec![Ident::new("orders")])), + create_table.partition_of + ); + // Check that table constraint was parsed + assert_eq!(1, create_table.constraints.len()); + match create_table.for_values { + Some(ForValues::From { .. }) => {} + _ => panic!("Expected ForValues::From"), + } + } + _ => panic!("Expected CreateTable"), + } +} From 495446518b5709b089704fc9a3146bb5a13be379 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 5 Jan 2026 17:26:12 +0900 Subject: [PATCH 02/30] Rename crate to pgmold-sqlparser for crates.io publishing. --- Cargo.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 177ab3db31..b4a8751dbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,14 +16,14 @@ # under the License. [package] -name = "sqlparser" -description = "Extensible SQL Lexer and Parser with support for ANSI SQL:2011" +name = "pgmold-sqlparser" +description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" version = "0.60.0" -authors = ["Apache DataFusion "] -homepage = "https://github.com/apache/datafusion-sqlparser-rs" -documentation = "https://docs.rs/sqlparser/" -keywords = ["ansi", "sql", "lexer", "parser"] -repository = "https://github.com/apache/datafusion-sqlparser-rs" +authors = ["Filipe Guerreiro "] +homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" +documentation = "https://docs.rs/pgmold-sqlparser/" +keywords = ["ansi", "sql", "lexer", "parser", "postgresql"] +repository = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" license = "Apache-2.0" include = [ "src/**/*.rs", From eb495f289c066465db37d88cf9620272af62ef5c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 5 Jan 2026 16:07:13 +0900 Subject: [PATCH 03/30] Add SECURITY DEFINER/INVOKER support for PostgreSQL functions. --- src/ast/ddl.rs | 19 +++++++++++++------ src/ast/mod.rs | 18 ++++++++++++++++++ src/ast/spans.rs | 8 +++++--- src/parser/mod.rs | 14 ++++++++++++++ tests/sqlparser_bigquery.rs | 1 + tests/sqlparser_mssql.rs | 2 ++ tests/sqlparser_postgres.rs | 29 +++++++++++++++++++++++++++++ 7 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 20d6db156a..4df9fc2f39 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -44,12 +44,12 @@ use crate::ast::{ ArgMode, AttachedToken, CommentDef, ConditionalStatements, CreateFunctionBody, CreateFunctionUsing, CreateTableLikeKind, CreateTableOptions, CreateViewParams, DataType, Expr, FileFormat, FunctionBehavior, FunctionCalledOnNull, FunctionDesc, FunctionDeterminismSpecifier, - FunctionParallel, HiveDistributionStyle, HiveFormat, HiveIOFormat, HiveRowFormat, - HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, OnCommit, - OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, RefreshModeKind, - RowAccessPolicy, SequenceOptions, Spanned, SqlOption, StorageSerializationPolicy, TableVersion, - Tag, TriggerEvent, TriggerExecBody, TriggerObject, TriggerPeriod, TriggerReferencing, Value, - ValueWithSpan, WrappedCollection, + FunctionParallel, FunctionSecurity, HiveDistributionStyle, HiveFormat, HiveIOFormat, + HiveRowFormat, HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, + OnCommit, OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, + RefreshModeKind, RowAccessPolicy, SequenceOptions, Spanned, SqlOption, + StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, TriggerExecBody, TriggerObject, + TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, WrappedCollection, }; use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; use crate::keywords::Keyword; @@ -3217,6 +3217,10 @@ pub struct CreateFunction { /// /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.html) pub parallel: Option, + /// SECURITY { DEFINER | INVOKER } + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.html) + pub security: Option, /// USING ... (Hive only) pub using: Option, /// Language used in a UDF definition. @@ -3283,6 +3287,9 @@ impl fmt::Display for CreateFunction { if let Some(parallel) = &self.parallel { write!(f, " {parallel}")?; } + if let Some(security) = &self.security { + write!(f, " {security}")?; + } if let Some(remote_connection) = &self.remote_connection { write!(f, " REMOTE WITH CONNECTION {remote_connection}")?; } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 9aeb790a5e..26b66e1b2c 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8771,6 +8771,24 @@ impl fmt::Display for FunctionBehavior { } } +/// Specifies whether the function is SECURITY DEFINER or SECURITY INVOKER. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum FunctionSecurity { + Definer, + Invoker, +} + +impl fmt::Display for FunctionSecurity { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + FunctionSecurity::Definer => write!(f, "SECURITY DEFINER"), + FunctionSecurity::Invoker => write!(f, "SECURITY INVOKER"), + } + } +} + /// These attributes describe the behavior of the function when called with a null argument. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ea4e1e9ae2..60f95a2d1b 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -553,8 +553,8 @@ impl Spanned for CreateTable { cluster_by: _, // todo, BigQuery specific clustered_by: _, // todo, Hive specific inherits: _, // todo, PostgreSQL specific - partition_of: _, // todo, PostgreSQL specific - for_values: _, // todo, PostgreSQL specific + partition_of, + for_values, strict: _, // bool copy_grants: _, // bool enable_schema_evolution: _, // bool @@ -585,7 +585,9 @@ impl Spanned for CreateTable { .chain(columns.iter().map(|i| i.span())) .chain(constraints.iter().map(|i| i.span())) .chain(query.iter().map(|i| i.span())) - .chain(clone.iter().map(|i| i.span())), + .chain(clone.iter().map(|i| i.span())) + .chain(partition_of.iter().map(|i| i.span())) + .chain(for_values.iter().map(|i| i.span())), ) } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bf0dc49b6f..e123228d2d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5206,6 +5206,7 @@ impl<'a> Parser<'a> { function_body: Option, called_on_null: Option, parallel: Option, + security: Option, } let mut body = Body::default(); loop { @@ -5272,6 +5273,15 @@ impl<'a> Parser<'a> { } else { return self.expected("one of UNSAFE | RESTRICTED | SAFE", self.peek_token()); } + } else if self.parse_keyword(Keyword::SECURITY) { + ensure_not_set(&body.security, "SECURITY { DEFINER | INVOKER }")?; + if self.parse_keyword(Keyword::DEFINER) { + body.security = Some(FunctionSecurity::Definer); + } else if self.parse_keyword(Keyword::INVOKER) { + body.security = Some(FunctionSecurity::Invoker); + } else { + return self.expected("DEFINER or INVOKER", self.peek_token()); + } } else if self.parse_keyword(Keyword::RETURN) { ensure_not_set(&body.function_body, "RETURN")?; body.function_body = Some(CreateFunctionBody::Return(self.parse_expr()?)); @@ -5290,6 +5300,7 @@ impl<'a> Parser<'a> { behavior: body.behavior, called_on_null: body.called_on_null, parallel: body.parallel, + security: body.security, language: body.language, function_body: body.function_body, if_not_exists: false, @@ -5327,6 +5338,7 @@ impl<'a> Parser<'a> { behavior: None, called_on_null: None, parallel: None, + security: None, language: None, determinism_specifier: None, options: None, @@ -5409,6 +5421,7 @@ impl<'a> Parser<'a> { behavior: None, called_on_null: None, parallel: None, + security: None, })) } @@ -5498,6 +5511,7 @@ impl<'a> Parser<'a> { behavior: None, called_on_null: None, parallel: None, + security: None, })) } diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index 24b9efcaa6..8b4f84d499 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -2294,6 +2294,7 @@ fn test_bigquery_create_function() { remote_connection: None, called_on_null: None, parallel: None, + security: None, }) ); diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index cce3ccef53..8320658e2d 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -266,6 +266,7 @@ fn parse_create_function() { behavior: None, called_on_null: None, parallel: None, + security: None, using: None, language: None, determinism_specifier: None, @@ -439,6 +440,7 @@ fn parse_create_function_parameter_default_values() { behavior: None, called_on_null: None, parallel: None, + security: None, using: None, language: None, determinism_specifier: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index dc4dcf5257..389b61fd98 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4262,6 +4262,7 @@ $$"#; behavior: None, called_on_null: None, parallel: None, + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF str1 <> str2 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4303,6 +4304,7 @@ $$"#; behavior: None, called_on_null: None, parallel: None, + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF int1 <> 0 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4348,6 +4350,7 @@ $$"#; behavior: None, called_on_null: None, parallel: None, + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF a <> b THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4393,6 +4396,7 @@ $$"#; behavior: None, called_on_null: None, parallel: None, + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF int1 <> int2 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4431,6 +4435,7 @@ $$"#; behavior: None, called_on_null: None, parallel: None, + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString { @@ -4472,6 +4477,7 @@ fn parse_create_function() { behavior: Some(FunctionBehavior::Immutable), called_on_null: Some(FunctionCalledOnNull::Strict), parallel: Some(FunctionParallel::Safe), + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::SingleQuotedString("select $1 + $2;".into())).with_empty_span() @@ -4502,6 +4508,27 @@ fn parse_create_function_detailed() { ); } +#[test] +fn parse_create_function_with_security() { + let sql = + "CREATE FUNCTION test_fn() RETURNS void LANGUAGE sql SECURITY DEFINER AS $$ SELECT 1 $$"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateFunction(CreateFunction { security, .. }) => { + assert_eq!(security, Some(FunctionSecurity::Definer)); + } + _ => panic!("Expected CreateFunction"), + } + + let sql2 = + "CREATE FUNCTION test_fn() RETURNS void LANGUAGE sql SECURITY INVOKER AS $$ SELECT 1 $$"; + match pg_and_generic().verified_stmt(sql2) { + Statement::CreateFunction(CreateFunction { security, .. }) => { + assert_eq!(security, Some(FunctionSecurity::Invoker)); + } + _ => panic!("Expected CreateFunction"), + } +} + #[test] fn parse_incorrect_create_function_parallel() { let sql = "CREATE FUNCTION add(INTEGER, INTEGER) RETURNS INTEGER LANGUAGE SQL PARALLEL BLAH AS 'select $1 + $2;'"; @@ -4530,6 +4557,7 @@ fn parse_create_function_c_with_module_pathname() { behavior: Some(FunctionBehavior::Immutable), called_on_null: None, parallel: Some(FunctionParallel::Safe), + security: None, function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::SingleQuotedString("MODULE_PATHNAME".into())).with_empty_span() @@ -6155,6 +6183,7 @@ fn parse_trigger_related_functions() { behavior: None, called_on_null: None, parallel: None, + security: None, using: None, language: Some(Ident::new("plpgsql")), determinism_specifier: None, From 4094fc82a26de3d308c4352c631985cf0880eb8b Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 5 Jan 2026 16:54:53 +0900 Subject: [PATCH 04/30] Add SET configuration_parameter support for PostgreSQL functions. --- src/ast/ddl.rs | 22 ++++++++++++------- src/ast/mod.rs | 36 +++++++++++++++++++++++++++++++ src/parser/mod.rs | 17 +++++++++++++++ tests/sqlparser_bigquery.rs | 1 + tests/sqlparser_mssql.rs | 2 ++ tests/sqlparser_postgres.rs | 42 +++++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 4df9fc2f39..1c191d3537 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -43,13 +43,14 @@ use crate::ast::{ }, ArgMode, AttachedToken, CommentDef, ConditionalStatements, CreateFunctionBody, CreateFunctionUsing, CreateTableLikeKind, CreateTableOptions, CreateViewParams, DataType, Expr, - FileFormat, FunctionBehavior, FunctionCalledOnNull, FunctionDesc, FunctionDeterminismSpecifier, - FunctionParallel, FunctionSecurity, HiveDistributionStyle, HiveFormat, HiveIOFormat, - HiveRowFormat, HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, - OnCommit, OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, - RefreshModeKind, RowAccessPolicy, SequenceOptions, Spanned, SqlOption, - StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, TriggerExecBody, TriggerObject, - TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, WrappedCollection, + FileFormat, FunctionBehavior, FunctionCalledOnNull, FunctionDefinitionSetParam, FunctionDesc, + FunctionDeterminismSpecifier, FunctionParallel, FunctionSecurity, HiveDistributionStyle, + HiveFormat, HiveIOFormat, HiveRowFormat, HiveSetLocation, Ident, InitializeKind, + MySQLColumnPosition, ObjectName, OnCommit, OneOrManyWithParens, OperateFunctionArg, + OrderByExpr, ProjectionSelect, Query, RefreshModeKind, RowAccessPolicy, SequenceOptions, + Spanned, SqlOption, StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, + TriggerExecBody, TriggerObject, TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, + WrappedCollection, }; use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; use crate::keywords::Keyword; @@ -3221,6 +3222,10 @@ pub struct CreateFunction { /// /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.html) pub security: Option, + /// SET configuration_parameter clauses + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.html) + pub set_params: Vec, /// USING ... (Hive only) pub using: Option, /// Language used in a UDF definition. @@ -3290,6 +3295,9 @@ impl fmt::Display for CreateFunction { if let Some(security) = &self.security { write!(f, " {security}")?; } + for set_param in &self.set_params { + write!(f, " {set_param}")?; + } if let Some(remote_connection) = &self.remote_connection { write!(f, " REMOTE WITH CONNECTION {remote_connection}")?; } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 26b66e1b2c..13764409a0 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8789,6 +8789,42 @@ impl fmt::Display for FunctionSecurity { } } +/// Value for a SET configuration parameter in a CREATE FUNCTION statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.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 FunctionSetValue { + /// SET param = value1, value2, ... + Values(Vec), + /// SET param FROM CURRENT + FromCurrent, +} + +/// A SET configuration_parameter clause in a CREATE FUNCTION statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createfunction.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 FunctionDefinitionSetParam { + pub name: Ident, + pub value: FunctionSetValue, +} + +impl fmt::Display for FunctionDefinitionSetParam { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "SET {} ", self.name)?; + match &self.value { + FunctionSetValue::Values(values) => { + write!(f, "= {}", display_comma_separated(values)) + } + FunctionSetValue::FromCurrent => write!(f, "FROM CURRENT"), + } + } +} + /// These attributes describe the behavior of the function when called with a null argument. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e123228d2d..b3c9c418b2 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5209,6 +5209,7 @@ impl<'a> Parser<'a> { security: Option, } let mut body = Body::default(); + let mut set_params: Vec = Vec::new(); loop { fn ensure_not_set(field: &Option, name: &str) -> Result<(), ParserError> { if field.is_some() { @@ -5282,6 +5283,18 @@ impl<'a> Parser<'a> { } else { return self.expected("DEFINER or INVOKER", self.peek_token()); } + } else if self.parse_keyword(Keyword::SET) { + let name = self.parse_identifier()?; + let value = if self.parse_keywords(&[Keyword::FROM, Keyword::CURRENT]) { + FunctionSetValue::FromCurrent + } else { + if !self.consume_token(&Token::Eq) && !self.parse_keyword(Keyword::TO) { + return self.expected("= or TO", self.peek_token()); + } + let values = self.parse_comma_separated(Parser::parse_expr)?; + FunctionSetValue::Values(values) + }; + set_params.push(FunctionDefinitionSetParam { name, value }); } else if self.parse_keyword(Keyword::RETURN) { ensure_not_set(&body.function_body, "RETURN")?; body.function_body = Some(CreateFunctionBody::Return(self.parse_expr()?)); @@ -5301,6 +5314,7 @@ impl<'a> Parser<'a> { called_on_null: body.called_on_null, parallel: body.parallel, security: body.security, + set_params, language: body.language, function_body: body.function_body, if_not_exists: false, @@ -5339,6 +5353,7 @@ impl<'a> Parser<'a> { called_on_null: None, parallel: None, security: None, + set_params: vec![], language: None, determinism_specifier: None, options: None, @@ -5422,6 +5437,7 @@ impl<'a> Parser<'a> { called_on_null: None, parallel: None, security: None, + set_params: vec![], })) } @@ -5512,6 +5528,7 @@ impl<'a> Parser<'a> { called_on_null: None, parallel: None, security: None, + set_params: vec![], })) } diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index 8b4f84d499..2bdeba912f 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -2295,6 +2295,7 @@ fn test_bigquery_create_function() { called_on_null: None, parallel: None, security: None, + set_params: vec![], }) ); diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 8320658e2d..b7a7289f72 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -267,6 +267,7 @@ fn parse_create_function() { called_on_null: None, parallel: None, security: None, + set_params: vec![], using: None, language: None, determinism_specifier: None, @@ -441,6 +442,7 @@ fn parse_create_function_parameter_default_values() { called_on_null: None, parallel: None, security: None, + set_params: vec![], using: None, language: None, determinism_specifier: None, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 389b61fd98..40058b88fd 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -4263,6 +4263,7 @@ $$"#; called_on_null: None, parallel: None, security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF str1 <> str2 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4305,6 +4306,7 @@ $$"#; called_on_null: None, parallel: None, security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF int1 <> 0 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4351,6 +4353,7 @@ $$"#; called_on_null: None, parallel: None, security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF a <> b THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4397,6 +4400,7 @@ $$"#; called_on_null: None, parallel: None, security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString {value: "\nBEGIN\n IF int1 <> int2 THEN\n RETURN TRUE;\n ELSE\n RETURN FALSE;\n END IF;\nEND;\n".to_owned(), tag: None})).with_empty_span() @@ -4436,6 +4440,7 @@ $$"#; called_on_null: None, parallel: None, security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::DollarQuotedString(DollarQuotedString { @@ -4478,6 +4483,7 @@ fn parse_create_function() { called_on_null: Some(FunctionCalledOnNull::Strict), parallel: Some(FunctionParallel::Safe), security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::SingleQuotedString("select $1 + $2;".into())).with_empty_span() @@ -4529,6 +4535,40 @@ fn parse_create_function_with_security() { } } +#[test] +fn parse_create_function_with_set_params() { + let sql = + "CREATE FUNCTION test_fn() RETURNS void LANGUAGE sql SET search_path = auth, pg_temp, public AS $$ SELECT 1 $$"; + match pg_and_generic().verified_stmt(sql) { + Statement::CreateFunction(CreateFunction { set_params, .. }) => { + assert_eq!(set_params.len(), 1); + assert_eq!(set_params[0].name.to_string(), "search_path"); + } + _ => panic!("Expected CreateFunction"), + } + + // Test multiple SET params + let sql2 = + "CREATE FUNCTION test_fn() RETURNS void LANGUAGE sql SET search_path = public SET statement_timeout = '5s' AS $$ SELECT 1 $$"; + match pg_and_generic().verified_stmt(sql2) { + Statement::CreateFunction(CreateFunction { set_params, .. }) => { + assert_eq!(set_params.len(), 2); + } + _ => panic!("Expected CreateFunction"), + } + + // Test FROM CURRENT + let sql3 = + "CREATE FUNCTION test_fn() RETURNS void LANGUAGE sql SET search_path FROM CURRENT AS $$ SELECT 1 $$"; + match pg_and_generic().verified_stmt(sql3) { + Statement::CreateFunction(CreateFunction { set_params, .. }) => { + assert_eq!(set_params.len(), 1); + assert!(matches!(set_params[0].value, FunctionSetValue::FromCurrent)); + } + _ => panic!("Expected CreateFunction"), + } +} + #[test] fn parse_incorrect_create_function_parallel() { let sql = "CREATE FUNCTION add(INTEGER, INTEGER) RETURNS INTEGER LANGUAGE SQL PARALLEL BLAH AS 'select $1 + $2;'"; @@ -4558,6 +4598,7 @@ fn parse_create_function_c_with_module_pathname() { called_on_null: None, parallel: Some(FunctionParallel::Safe), security: None, + set_params: vec![], function_body: Some(CreateFunctionBody::AsBeforeOptions { body: Expr::Value( (Value::SingleQuotedString("MODULE_PATHNAME".into())).with_empty_span() @@ -6184,6 +6225,7 @@ fn parse_trigger_related_functions() { called_on_null: None, parallel: None, security: None, + set_params: vec![], using: None, language: Some(Ident::new("plpgsql")), determinism_specifier: None, From 17910f2f3a0f19c372f9647513673247aef6374c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 5 Jan 2026 18:35:08 +0900 Subject: [PATCH 05/30] Bump version to 0.60.1 and add missing Spanned impls. --- Cargo.toml | 2 +- src/ast/spans.rs | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b4a8751dbc..7f6ff0b4c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "pgmold-sqlparser" description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" -version = "0.60.0" +version = "0.60.1" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 60f95a2d1b..7f35c1e898 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -34,14 +34,14 @@ use super::{ ColumnOption, ColumnOptionDef, ConditionalStatementBlock, ConditionalStatements, ConflictTarget, ConnectBy, ConstraintCharacteristics, CopySource, CreateIndex, CreateTable, CreateTableOptions, Cte, Delete, DoUpdate, ExceptSelectItem, ExcludeSelectItem, Expr, - ExprWithAlias, Fetch, FromTable, Function, FunctionArg, FunctionArgExpr, + ExprWithAlias, Fetch, ForValues, FromTable, Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList, FunctionArguments, GroupByExpr, HavingBound, IfStatement, IlikeSelectItem, IndexColumn, Insert, Interpolate, InterpolateExpr, Join, JoinConstraint, JoinOperator, JsonPath, JsonPathElem, LateralView, LimitClause, MatchRecognizePattern, Measure, Merge, MergeAction, MergeClause, MergeInsertExpr, MergeInsertKind, MergeUpdateExpr, NamedParenthesizedList, NamedWindowDefinition, ObjectName, ObjectNamePart, Offset, OnConflict, OnConflictAction, OnInsert, OpenStatement, OrderBy, - OrderByExpr, OrderByKind, OutputClause, Partition, PivotValueSource, ProjectionSelect, Query, + OrderByExpr, OrderByKind, OutputClause, Partition, PartitionBoundValue, PivotValueSource, ProjectionSelect, Query, RaiseStatement, RaiseStatementValue, ReferentialAction, RenameSelectItem, ReplaceSelectElement, ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, SqlOption, Statement, Subscript, SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor, TableObject, @@ -592,6 +592,31 @@ impl Spanned for CreateTable { } } +impl Spanned for PartitionBoundValue { + fn span(&self) -> Span { + match self { + PartitionBoundValue::Expr(expr) => expr.span(), + PartitionBoundValue::MinValue => Span::empty(), + PartitionBoundValue::MaxValue => Span::empty(), + } + } +} + +impl Spanned for ForValues { + fn span(&self) -> Span { + match self { + ForValues::In(exprs) => union_spans(exprs.iter().map(|e| e.span())), + ForValues::From { from, to } => union_spans( + from.iter() + .map(|v| v.span()) + .chain(to.iter().map(|v| v.span())), + ), + ForValues::With { .. } => Span::empty(), + ForValues::Default => Span::empty(), + } + } +} + impl Spanned for ColumnDef { fn span(&self) -> Span { let ColumnDef { From 4176b66d10def79ccce6ada39ea186b399c5eca1 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 30 Mar 2026 22:23:36 +0900 Subject: [PATCH 06/30] feat(ddl): add ForceRowLevelSecurity and NoForceRowLevelSecurity ALTER TABLE operations (#1) PostgreSQL supports FORCE ROW LEVEL SECURITY and NO FORCE ROW LEVEL SECURITY as ALTER TABLE operations. Add parsing support for both variants. --- Cargo.toml | 2 +- src/ast/ddl.rs | 14 ++++++++++++++ src/ast/spans.rs | 2 ++ src/parser/mod.rs | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 7f6ff0b4c1..da3ed3c345 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "pgmold-sqlparser" description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" -version = "0.60.1" +version = "0.60.2" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 1c191d3537..49739aec05 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -277,6 +277,14 @@ pub enum AlterTableOperation { /// /// Note: this is a PostgreSQL-specific operation. EnableRowLevelSecurity, + /// `FORCE ROW LEVEL SECURITY` + /// + /// Note: this is a PostgreSQL-specific operation. + ForceRowLevelSecurity, + /// `NO FORCE ROW LEVEL SECURITY` + /// + /// Note: this is a PostgreSQL-specific operation. + NoForceRowLevelSecurity, /// `ENABLE RULE rewrite_rule_name` /// /// Note: this is a PostgreSQL-specific operation. @@ -757,6 +765,12 @@ impl fmt::Display for AlterTableOperation { AlterTableOperation::EnableRowLevelSecurity => { write!(f, "ENABLE ROW LEVEL SECURITY") } + AlterTableOperation::ForceRowLevelSecurity => { + write!(f, "FORCE ROW LEVEL SECURITY") + } + AlterTableOperation::NoForceRowLevelSecurity => { + write!(f, "NO FORCE ROW LEVEL SECURITY") + } AlterTableOperation::EnableRule { name } => { write!(f, "ENABLE RULE {name}") } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7f35c1e898..513f62f415 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1081,6 +1081,8 @@ impl Spanned for AlterTableOperation { partition, } => name.span.union_opt(&partition.as_ref().map(|i| i.span)), AlterTableOperation::DisableRowLevelSecurity => Span::empty(), + AlterTableOperation::ForceRowLevelSecurity => Span::empty(), + AlterTableOperation::NoForceRowLevelSecurity => Span::empty(), AlterTableOperation::DisableRule { name } => name.span, AlterTableOperation::DisableTrigger { name } => name.span, AlterTableOperation::DropConstraint { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b3c9c418b2..9ab30148ee 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9545,6 +9545,10 @@ impl<'a> Parser<'a> { self.peek_token(), ); } + } else if self.parse_keywords(&[Keyword::FORCE, Keyword::ROW, Keyword::LEVEL, Keyword::SECURITY]) { + AlterTableOperation::ForceRowLevelSecurity + } else if self.parse_keywords(&[Keyword::NO, Keyword::FORCE, Keyword::ROW, Keyword::LEVEL, Keyword::SECURITY]) { + AlterTableOperation::NoForceRowLevelSecurity } else if self.parse_keywords(&[Keyword::CLEAR, Keyword::PROJECTION]) && dialect_of!(self is ClickHouseDialect|GenericDialect) { From ff0cada5d254f2d8e4f4a05d1fd455616d0e1f1e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 30 Mar 2026 22:24:26 +0900 Subject: [PATCH 07/30] chore: bump version to 0.60.4 (#2) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index da3ed3c345..44ace2783a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "pgmold-sqlparser" description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" -version = "0.60.2" +version = "0.60.4" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" From 723a21c21f67b4825127f8b51e9df830599e5110 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 30 Mar 2026 22:59:53 +0900 Subject: [PATCH 08/30] feat(data-type): add SETOF type modifier for PostgreSQL function return types (#3) --- Cargo.toml | 2 +- src/ast/data_type.rs | 6 ++++++ src/keywords.rs | 1 + src/parser/mod.rs | 4 ++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 44ace2783a..c581e9e7d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "pgmold-sqlparser" description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" -version = "0.60.4" +version = "0.60.5" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" diff --git a/src/ast/data_type.rs b/src/ast/data_type.rs index 6da6a90d06..fec5646509 100644 --- a/src/ast/data_type.rs +++ b/src/ast/data_type.rs @@ -478,6 +478,11 @@ pub enum DataType { /// /// [PostgreSQL]: https://www.postgresql.org/docs/current/plpgsql-trigger.html Trigger, + /// SETOF type modifier for [PostgreSQL] function return types, + /// e.g. `CREATE FUNCTION ... RETURNS SETOF text`. + /// + /// [PostgreSQL]: https://www.postgresql.org/docs/current/sql-createfunction.html + SetOf(Box), /// Any data type, used in BigQuery UDF definitions for templated parameters, see [BigQuery]. /// /// [BigQuery]: https://cloud.google.com/bigquery/docs/user-defined-functions#templated-sql-udf-parameters @@ -794,6 +799,7 @@ impl fmt::Display for DataType { } DataType::Unspecified => Ok(()), DataType::Trigger => write!(f, "TRIGGER"), + DataType::SetOf(inner) => write!(f, "SETOF {inner}"), DataType::AnyType => write!(f, "ANY TYPE"), DataType::Table(fields) => match fields { Some(fields) => { diff --git a/src/keywords.rs b/src/keywords.rs index 87c77379c2..39c35009d7 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -921,6 +921,7 @@ define_keywords!( SESSION_USER, SET, SETERROR, + SETOF, SETS, SETTINGS, SHARE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9ab30148ee..f2ee435cf0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -11354,6 +11354,10 @@ impl<'a> Parser<'a> { Ok(DataType::Tuple(field_defs)) } Keyword::TRIGGER => Ok(DataType::Trigger), + Keyword::SETOF => { + let inner = self.parse_data_type()?; + Ok(DataType::SetOf(Box::new(inner))) + } Keyword::ANY if self.peek_keyword(Keyword::TYPE) => { let _ = self.parse_keyword(Keyword::TYPE); Ok(DataType::AnyType) From f146c474d6a7d774e229b892c93d422c98672f0f Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:42 +0900 Subject: [PATCH 09/30] feat(ast): add ExclusionConstraint and ExclusionElement types --- src/ast/mod.rs | 5 ++- src/ast/spans.rs | 1 + src/ast/table_constraints.rs | 79 ++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 13764409a0..4caf89154c 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -129,8 +129,9 @@ mod dml; pub mod helpers; pub mod table_constraints; pub use table_constraints::{ - CheckConstraint, ForeignKeyConstraint, FullTextOrSpatialConstraint, IndexConstraint, - PrimaryKeyConstraint, TableConstraint, UniqueConstraint, + CheckConstraint, ExclusionConstraint, ExclusionElement, ForeignKeyConstraint, + FullTextOrSpatialConstraint, IndexConstraint, PrimaryKeyConstraint, TableConstraint, + UniqueConstraint, }; mod operator; mod query; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 513f62f415..2a824cae28 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -646,6 +646,7 @@ impl Spanned for TableConstraint { TableConstraint::Check(constraint) => constraint.span(), TableConstraint::Index(constraint) => constraint.span(), TableConstraint::FulltextOrSpatial(constraint) => constraint.span(), + TableConstraint::Exclusion(constraint) => constraint.span(), } } } diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index ddf0c12539..0d36cc2647 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -101,6 +101,9 @@ pub enum TableConstraint { /// [1]: https://dev.mysql.com/doc/refman/8.0/en/fulltext-natural-language.html /// [2]: https://dev.mysql.com/doc/refman/8.0/en/spatial-types.html FulltextOrSpatial(FullTextOrSpatialConstraint), + /// PostgreSQL `EXCLUDE` constraint: + /// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` + Exclusion(ExclusionConstraint), } impl From for TableConstraint { @@ -139,6 +142,12 @@ impl From for TableConstraint { } } +impl From for TableConstraint { + fn from(constraint: ExclusionConstraint) -> Self { + TableConstraint::Exclusion(constraint) + } +} + impl fmt::Display for TableConstraint { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { @@ -148,6 +157,7 @@ impl fmt::Display for TableConstraint { TableConstraint::Check(constraint) => constraint.fmt(f), TableConstraint::Index(constraint) => constraint.fmt(f), TableConstraint::FulltextOrSpatial(constraint) => constraint.fmt(f), + TableConstraint::Exclusion(constraint) => constraint.fmt(f), } } } @@ -518,3 +528,72 @@ impl crate::ast::Spanned for UniqueConstraint { ) } } + +/// One element in an `EXCLUDE` constraint's element list: +/// ` WITH ` +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExclusionElement { + pub expr: Expr, + pub operator: String, +} + +impl fmt::Display for ExclusionElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} WITH {}", self.expr, self.operator) + } +} + +/// PostgreSQL `EXCLUDE` constraint: +/// `[ CONSTRAINT ] EXCLUDE [ USING ] ( WITH [, ...] ) [ INCLUDE () ] [ WHERE () ]` +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct ExclusionConstraint { + pub name: Option, + pub index_method: Option, + pub elements: Vec, + pub include: Vec, + pub where_clause: Option>, + pub characteristics: Option, +} + +impl fmt::Display for ExclusionConstraint { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use crate::ast::ddl::display_constraint_name; + write!(f, "{}EXCLUDE", display_constraint_name(&self.name))?; + if let Some(method) = &self.index_method { + write!(f, " USING {method}")?; + } + write!(f, " ({})", display_comma_separated(&self.elements))?; + if !self.include.is_empty() { + write!(f, " INCLUDE ({})", display_comma_separated(&self.include))?; + } + if let Some(predicate) = &self.where_clause { + write!(f, " WHERE ({predicate})")?; + } + if let Some(characteristics) = &self.characteristics { + write!(f, " {characteristics}")?; + } + Ok(()) + } +} + +impl crate::ast::Spanned for ExclusionConstraint { + fn span(&self) -> Span { + fn union_spans>(iter: I) -> Span { + Span::union_iter(iter) + } + + union_spans( + self.name + .iter() + .map(|i| i.span) + .chain(self.index_method.iter().map(|i| i.span)) + .chain(self.include.iter().map(|i| i.span)) + .chain(self.where_clause.iter().map(|e| e.span())) + .chain(self.characteristics.iter().map(|c| c.span())), + ) + } +} From c021efbf3acaf6d19a5a17b5d8f39c12d23f8e5e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:46 +0900 Subject: [PATCH 10/30] feat(parser): parse EXCLUDE constraints in CREATE TABLE and ALTER TABLE --- src/parser/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f2ee435cf0..d825474eaf 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9171,6 +9171,50 @@ impl<'a> Parser<'a> { .into(), )) } + Token::Word(w) if w.keyword == Keyword::EXCLUDE => { + let index_method = if self.parse_keyword(Keyword::USING) { + Some(self.parse_identifier()?) + } else { + None + }; + + self.expect_token(&Token::LParen)?; + let elements = + self.parse_comma_separated(|p| p.parse_exclusion_element())?; + self.expect_token(&Token::RParen)?; + + let include = if self.parse_keyword(Keyword::INCLUDE) { + self.expect_token(&Token::LParen)?; + let cols = self.parse_comma_separated(|p| p.parse_identifier())?; + self.expect_token(&Token::RParen)?; + cols + } else { + vec![] + }; + + let where_clause = if self.parse_keyword(Keyword::WHERE) { + self.expect_token(&Token::LParen)?; + let predicate = self.parse_expr()?; + self.expect_token(&Token::RParen)?; + Some(Box::new(predicate)) + } else { + None + }; + + let characteristics = self.parse_constraint_characteristics()?; + + Ok(Some( + ExclusionConstraint { + name, + index_method, + elements, + include, + where_clause, + characteristics, + } + .into(), + )) + } _ => { if name.is_some() { self.expected("PRIMARY, UNIQUE, FOREIGN, or CHECK", next_token) @@ -9182,6 +9226,14 @@ impl<'a> Parser<'a> { } } + fn parse_exclusion_element(&mut self) -> Result { + let expr = self.parse_expr()?; + self.expect_keyword_is(Keyword::WITH)?; + let operator_token = self.next_token(); + let operator = operator_token.token.to_string(); + Ok(ExclusionElement { expr, operator }) + } + fn parse_optional_nulls_distinct(&mut self) -> Result { Ok(if self.parse_keyword(Keyword::NULLS) { let not = self.parse_keyword(Keyword::NOT); From c1227c94e8e0cb89110e79a1e6064b2a945cac6f Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:01:51 +0900 Subject: [PATCH 11/30] test: add EXCLUDE constraint parsing tests --- tests/sqlparser_postgres.rs | 134 ++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 40058b88fd..c21fbbe7f9 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -7753,3 +7753,137 @@ CONSTRAINT check_date CHECK (order_date >= '2023-01-01')\ _ => panic!("Expected CreateTable"), } } + +#[test] +fn parse_exclude_constraint_basic() { + let sql = + "CREATE TABLE t (room INT, CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.name, Some(Ident::new("no_overlap"))); + assert_eq!(c.index_method, Some(Ident::new("gist"))); + assert_eq!(c.elements.len(), 1); + assert_eq!(c.elements[0].operator, "="); + assert_eq!(c.include.len(), 0); + assert!(c.where_clause.is_none()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_multi_element() { + let sql = + "CREATE TABLE t (room INT, during INT, EXCLUDE USING gist (room WITH =, during WITH &&))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.name.is_none()); + assert_eq!(c.index_method, Some(Ident::new("gist"))); + assert_eq!(c.elements.len(), 2); + assert_eq!(c.elements[0].operator, "="); + assert_eq!(c.elements[1].operator, "&&"); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_with_where() { + let sql = + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) WHERE (col > 0))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.where_clause.is_some()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_with_include() { + let sql = + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) INCLUDE (col))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert_eq!(c.include, vec![Ident::new("col")]); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_no_using() { + let sql = "CREATE TABLE t (col INT, EXCLUDE (col WITH =))"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + assert!(c.index_method.is_none()); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_deferrable() { + let sql = + "CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =) DEFERRABLE INITIALLY DEFERRED)"; + match pg().verified_stmt(sql) { + Statement::CreateTable(create_table) => { + assert_eq!(1, create_table.constraints.len()); + match &create_table.constraints[0] { + TableConstraint::Exclusion(c) => { + let characteristics = c.characteristics.as_ref().unwrap(); + assert_eq!(characteristics.deferrable, Some(true)); + assert_eq!( + characteristics.initially, + Some(DeferrableInitial::Deferred) + ); + } + other => panic!("Expected Exclusion, got {other:?}"), + } + } + _ => panic!("Expected CreateTable"), + } +} + +#[test] +fn parse_exclude_constraint_in_alter_table() { + let sql = + "ALTER TABLE t ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =)"; + pg().verified_stmt(sql); +} + +#[test] +fn roundtrip_exclude_constraint() { + let sql = "CREATE TABLE t (CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =, during WITH &&) INCLUDE (id) WHERE (active = true))"; + pg().verified_stmt(sql); +} From d819ea0cdde3def62fd4ee163ff030c20acff6b2 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Thu, 16 Apr 2026 10:02:06 +0900 Subject: [PATCH 12/30] chore: bump version to 0.60.6 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c581e9e7d2..5f196157f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "pgmold-sqlparser" description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params)" -version = "0.60.5" +version = "0.60.6" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" From a736c67c332ad4da371fcd093128cf553f3aa513 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:11:32 +0900 Subject: [PATCH 13/30] fix: add missing doc comments on ExclusionConstraint fields --- .worktrees/fix-pr-2307-ci | 1 + CLAUDE.md | 90 ++++++++++++++++++++++++++++++++++++ src/ast/table_constraints.rs | 8 ++++ 3 files changed, 99 insertions(+) create mode 160000 .worktrees/fix-pr-2307-ci create mode 100644 CLAUDE.md diff --git a/.worktrees/fix-pr-2307-ci b/.worktrees/fix-pr-2307-ci new file mode 160000 index 0000000000..90803e045d --- /dev/null +++ b/.worktrees/fix-pr-2307-ci @@ -0,0 +1 @@ +Subproject commit 90803e045da78472949bda9a1edab5fa5ff37441 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..1a35c4881d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +```bash +# Build +cargo build +cargo build --all-features + +# Run all tests +cargo test --all-features + +# Run a single test +cargo test test_name --all-features + +# Run tests for a specific dialect +cargo test sqlparser_postgres --all-features + +# Lint +cargo clippy --all-targets --all-features -- -D warnings + +# Format +cargo fmt --all + +# Check (faster than full build) +cargo check --all-targets --all-features + +# Build docs +cargo doc --document-private-items --no-deps --workspace --all-features + +# Run benchmarks (from sqlparser_bench directory) +cd sqlparser_bench && cargo bench +``` + +## Crate Features + +- `serde`: Adds Serialize/Deserialize for all AST nodes +- `visitor`: Adds a Visitor for recursively walking the AST +- `recursive-protection` (default): Stack overflow protection +- `json_example`: For CLI example only + +## Architecture + +This is an extensible SQL lexer and parser that produces an Abstract Syntax Tree (AST). + +### Core Components + +- **`src/tokenizer.rs`**: Lexer that converts SQL text into tokens. `Tokenizer::new(dialect, sql).tokenize()` returns `Vec`. + +- **`src/parser/mod.rs`**: Recursive descent parser using Pratt parsing for expressions. Entry point is `Parser::parse_sql(&dialect, sql)` returning `Vec`. + +- **`src/ast/mod.rs`**: AST type definitions. `Statement` is the top-level enum. Key types: `Query`, `Select`, `Expr`, `DataType`, `ObjectName`. + +- **`src/dialect/mod.rs`**: SQL dialect trait and implementations. Each dialect (PostgreSQL, MySQL, etc.) customizes parsing behavior. `GenericDialect` is the most permissive. + +### Dialect System + +Dialects customize parsing via the `Dialect` trait. Methods control identifier quoting, keyword handling, and syntax variations. Dialect-specific features should work with both the specific dialect AND `GenericDialect`. + +### Testing Patterns + +Tests use `TestedDialects` from `src/test_utils.rs`: + +```rust +use sqlparser::test_utils::*; + +// Test across all dialects +all_dialects().verified_stmt("SELECT 1"); + +// Test specific dialects +TestedDialects::new(vec![Box::new(PostgreSqlDialect {})]).verified_stmt("..."); + +// Test all dialects except specific ones +all_dialects_except(|d| d.is::()).verified_stmt("..."); +``` + +Key test helpers: +- `verified_stmt(sql)`: Parse and verify round-trip serialization +- `verified_query(sql)`: Same but returns `Query` +- `one_statement_parses_to(sql, canonical)`: Test with different canonical form + +### Round-Trip Invariant + +AST nodes implement `Display` to reproduce the original SQL (minus comments/whitespace). Tests verify `parse(sql).to_string() == sql`. + +### Source Spans + +AST nodes include `Span` information for source locations. When constructing AST nodes manually, use `Span::empty()`. diff --git a/src/ast/table_constraints.rs b/src/ast/table_constraints.rs index a235ffa2f4..3c37fa407c 100644 --- a/src/ast/table_constraints.rs +++ b/src/ast/table_constraints.rs @@ -570,7 +570,9 @@ impl crate::ast::Spanned for UniqueConstraint { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct ExclusionElement { + /// The column or expression to exclude on. pub expr: Expr, + /// The operator to use for the exclusion check (e.g. `=`, `&&`). pub operator: String, } @@ -586,11 +588,17 @@ impl fmt::Display for ExclusionElement { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct ExclusionConstraint { + /// Optional constraint name. pub name: Option, + /// Index access method (e.g. `gist`, `btree`). Defaults to `gist` if omitted. pub index_method: Option, + /// The list of `(element WITH operator)` pairs. pub elements: Vec, + /// Columns to include in the index via `INCLUDE (...)`. pub include: Vec, + /// Optional `WHERE (predicate)` for a partial exclusion constraint. pub where_clause: Option>, + /// `DEFERRABLE` / `INITIALLY DEFERRED` characteristics. pub characteristics: Option, } From 613c05f278a6507fbe08786b590a84f0612e7304 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:21:57 +0900 Subject: [PATCH 14/30] feat(ast): add CreateForeignDataWrapper and CreateForeignTable types - Add HANDLER and VALIDATOR keywords to the keyword list - Add FdwRoutineClause enum for HANDLER/NO HANDLER and VALIDATOR/NO VALIDATOR clauses - Add CreateForeignDataWrapper struct for CREATE FOREIGN DATA WRAPPER - Add CreateForeignTable struct for CREATE FOREIGN TABLE - Export new types from ast::mod and add Statement variants - Add spans.rs coverage returning Span::empty() for the new variants --- src/ast/ddl.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++- src/ast/mod.rs | 23 ++++++++--- src/ast/spans.rs | 2 + src/keywords.rs | 2 + 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index ad46d2f592..d21fed3c3a 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -42,7 +42,8 @@ use crate::ast::{ UniqueConstraint, }, ArgMode, AttachedToken, CommentDef, ConditionalStatements, CreateFunctionBody, - CreateFunctionUsing, CreateTableLikeKind, CreateTableOptions, CreateViewParams, DataType, Expr, + CreateFunctionUsing, CreateServerOption, CreateTableLikeKind, CreateTableOptions, + CreateViewParams, DataType, Expr, FileFormat, FunctionBehavior, FunctionCalledOnNull, FunctionDefinitionSetParam, FunctionDesc, FunctionDeterminismSpecifier, FunctionParallel, FunctionSecurity, HiveDistributionStyle, HiveFormat, HiveIOFormat, HiveRowFormat, HiveSetLocation, Ident, InitializeKind, @@ -5757,3 +5758,104 @@ impl From for crate::ast::Statement { crate::ast::Statement::AlterPolicy(v) } } + +/// The handler/validator clause of a `CREATE FOREIGN DATA WRAPPER` statement. +/// +/// Specifies either a named function or the absence of a function. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum FdwRoutineClause { + /// A named function, e.g. `HANDLER myhandler` or `VALIDATOR myvalidator`. + Function(ObjectName), + /// The `NO HANDLER` or `NO VALIDATOR` form. + NoFunction, +} + +/// A `CREATE FOREIGN DATA WRAPPER` statement. +/// +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigndatawrapper.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 CreateForeignDataWrapper { + /// The name of the foreign-data wrapper. + pub name: Ident, + /// Optional `HANDLER handler_function` or `NO HANDLER` clause. + pub handler: Option, + /// Optional `VALIDATOR validator_function` or `NO VALIDATOR` clause. + pub validator: Option, + /// Optional `OPTIONS (key 'value', ...)` clause. + pub options: Option>, +} + +impl fmt::Display for CreateForeignDataWrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "CREATE FOREIGN DATA WRAPPER {}", self.name)?; + if let Some(handler) = &self.handler { + match handler { + FdwRoutineClause::Function(name) => write!(f, " HANDLER {name}")?, + FdwRoutineClause::NoFunction => write!(f, " NO HANDLER")?, + } + } + if let Some(validator) = &self.validator { + match validator { + FdwRoutineClause::Function(name) => write!(f, " VALIDATOR {name}")?, + FdwRoutineClause::NoFunction => write!(f, " NO VALIDATOR")?, + } + } + if let Some(options) = &self.options { + write!(f, " OPTIONS ({})", display_comma_separated(options))?; + } + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateForeignDataWrapper) -> Self { + crate::ast::Statement::CreateForeignDataWrapper(v) + } +} + +/// A `CREATE FOREIGN TABLE` statement. +/// +/// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigntable.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 CreateForeignTable { + /// The foreign table name. + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + pub name: ObjectName, + /// Whether `IF NOT EXISTS` was specified. + pub if_not_exists: bool, + /// Column definitions. + pub columns: Vec, + /// The `SERVER server_name` clause. + pub server_name: Ident, + /// Optional `OPTIONS (key 'value', ...)` clause at the table level. + pub options: Option>, +} + +impl fmt::Display for CreateForeignTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "CREATE FOREIGN TABLE {if_not_exists}{name} ({columns}) SERVER {server_name}", + if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, + name = self.name, + columns = display_comma_separated(&self.columns), + server_name = self.server_name, + )?; + if let Some(options) = &self.options { + write!(f, " OPTIONS ({})", display_comma_separated(options))?; + } + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateForeignTable) -> Self { + crate::ast::Statement::CreateForeignTable(v) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 357b24f9c5..0b74be8911 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -69,11 +69,12 @@ pub use self::ddl::{ AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, - CreateConnector, CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, - CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, - DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, - DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, + CreateConnector, CreateDomain, CreateExtension, CreateForeignDataWrapper, CreateForeignTable, + CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, + CreatePolicy, CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTrigger, CreateView, + Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, + DropOperator, DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, + DropTrigger, FdwRoutineClause, ForValues, FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, OperatorClassItem, @@ -3699,6 +3700,16 @@ pub enum Statement { /// A `CREATE SERVER` statement. CreateServer(CreateServerStatement), /// ```sql + /// CREATE FOREIGN DATA WRAPPER + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigndatawrapper.html) + CreateForeignDataWrapper(CreateForeignDataWrapper), + /// ```sql + /// CREATE FOREIGN TABLE + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigntable.html) + CreateForeignTable(CreateForeignTable), + /// ```sql /// CREATE POLICY /// ``` /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createpolicy.html) @@ -5504,6 +5515,8 @@ impl fmt::Display for Statement { Statement::CreateServer(stmt) => { write!(f, "{stmt}") } + Statement::CreateForeignDataWrapper(stmt) => write!(f, "{stmt}"), + Statement::CreateForeignTable(stmt) => write!(f, "{stmt}"), Statement::CreatePolicy(policy) => write!(f, "{policy}"), Statement::CreateConnector(create_connector) => create_connector.fmt(f), Statement::CreateOperator(create_operator) => create_operator.fmt(f), diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 987d48b2c2..696e8bf63b 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -386,6 +386,8 @@ impl Spanned for Statement { Statement::DropOperatorClass(drop_operator_class) => drop_operator_class.span(), Statement::CreateSecret { .. } => Span::empty(), Statement::CreateServer { .. } => Span::empty(), + Statement::CreateForeignDataWrapper { .. } => Span::empty(), + Statement::CreateForeignTable { .. } => Span::empty(), Statement::CreateConnector { .. } => Span::empty(), Statement::CreateOperator(create_operator) => create_operator.span(), Statement::CreateOperatorFamily(create_operator_family) => { diff --git a/src/keywords.rs b/src/keywords.rs index 808e5f03d8..1ae28f0aae 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -476,6 +476,7 @@ define_keywords!( GROUPING, GROUPS, GZIP, + HANDLER, HASH, HASHES, HAVING, @@ -1130,6 +1131,7 @@ define_keywords!( VALID, VALIDATE, VALIDATION_MODE, + VALIDATOR, VALUE, VALUES, VALUE_OF, From 3d90838fa974b2a827f085a4bc92c81783815d8c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:22:00 +0900 Subject: [PATCH 15/30] feat(ast): add CREATE TEXT SEARCH CONFIGURATION/DICTIONARY/PARSER/TEMPLATE types --- src/ast/ddl.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++++ src/ast/mod.rs | 31 +++++++++++- src/ast/spans.rs | 8 +++ src/keywords.rs | 4 ++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index ad46d2f592..0257381bbf 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -5757,3 +5757,127 @@ impl From for crate::ast::Statement { crate::ast::Statement::AlterPolicy(v) } } + +/// `CREATE TEXT SEARCH CONFIGURATION` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateTextSearchConfiguration { + /// Name of the text search configuration being created. + pub name: ObjectName, + /// Options list — must include `PARSER = parser_name`. + pub options: Vec, +} + +impl fmt::Display for CreateTextSearchConfiguration { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE TEXT SEARCH CONFIGURATION {name} ({options})", + name = self.name, + options = display_comma_separated(&self.options), + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateTextSearchConfiguration) -> Self { + crate::ast::Statement::CreateTextSearchConfiguration(v) + } +} + +/// `CREATE TEXT SEARCH DICTIONARY` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateTextSearchDictionary { + /// Name of the text search dictionary being created. + pub name: ObjectName, + /// Options list — must include `TEMPLATE = template_name`. + pub options: Vec, +} + +impl fmt::Display for CreateTextSearchDictionary { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE TEXT SEARCH DICTIONARY {name} ({options})", + name = self.name, + options = display_comma_separated(&self.options), + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateTextSearchDictionary) -> Self { + crate::ast::Statement::CreateTextSearchDictionary(v) + } +} + +/// `CREATE TEXT SEARCH PARSER` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateTextSearchParser { + /// Name of the text search parser being created. + pub name: ObjectName, + /// Options list — must include `START`, `GETTOKEN`, `END`, `LEXTYPES` (and optionally `HEADLINE`). + pub options: Vec, +} + +impl fmt::Display for CreateTextSearchParser { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE TEXT SEARCH PARSER {name} ({options})", + name = self.name, + options = display_comma_separated(&self.options), + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateTextSearchParser) -> Self { + crate::ast::Statement::CreateTextSearchParser(v) + } +} + +/// `CREATE TEXT SEARCH TEMPLATE` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateTextSearchTemplate { + /// Name of the text search template being created. + pub name: ObjectName, + /// Options list — must include `LEXIZE` (and optionally `INIT`). + pub options: Vec, +} + +impl fmt::Display for CreateTextSearchTemplate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE TEXT SEARCH TEMPLATE {name} ({options})", + name = self.name, + options = display_comma_separated(&self.options), + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateTextSearchTemplate) -> Self { + crate::ast::Statement::CreateTextSearchTemplate(v) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 357b24f9c5..146b2e5472 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -71,7 +71,8 @@ pub use self::ddl::{ ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, + CreateTable, CreateTextSearchConfiguration, CreateTextSearchDictionary, CreateTextSearchParser, + CreateTextSearchTemplate, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, @@ -3975,6 +3976,30 @@ pub enum Statement { /// CreateCollation(CreateCollation), /// ```sql + /// CREATE TEXT SEARCH CONFIGURATION name ( PARSER = parser_name ) + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateTextSearchConfiguration(CreateTextSearchConfiguration), + /// ```sql + /// CREATE TEXT SEARCH DICTIONARY name ( TEMPLATE = template_name [, option = value, ...] ) + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateTextSearchDictionary(CreateTextSearchDictionary), + /// ```sql + /// CREATE TEXT SEARCH PARSER name ( START = start_fn, GETTOKEN = gettoken_fn, END = end_fn, LEXTYPES = lextypes_fn [, HEADLINE = headline_fn] ) + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateTextSearchParser(CreateTextSearchParser), + /// ```sql + /// CREATE TEXT SEARCH TEMPLATE name ( [INIT = init_fn,] LEXIZE = lexize_fn ) + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateTextSearchTemplate(CreateTextSearchTemplate), + /// ```sql /// DROP EXTENSION [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] /// ``` /// Note: this is a PostgreSQL-specific statement. @@ -5457,6 +5482,10 @@ impl fmt::Display for Statement { Statement::CreateIndex(create_index) => create_index.fmt(f), Statement::CreateExtension(create_extension) => write!(f, "{create_extension}"), Statement::CreateCollation(create_collation) => write!(f, "{create_collation}"), + Statement::CreateTextSearchConfiguration(v) => write!(f, "{v}"), + Statement::CreateTextSearchDictionary(v) => write!(f, "{v}"), + Statement::CreateTextSearchParser(v) => write!(f, "{v}"), + Statement::CreateTextSearchTemplate(v) => write!(f, "{v}"), Statement::DropExtension(drop_extension) => write!(f, "{drop_extension}"), Statement::DropOperator(drop_operator) => write!(f, "{drop_operator}"), Statement::DropOperatorFamily(drop_operator_family) => { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 987d48b2c2..7f15b08b68 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -265,6 +265,10 @@ impl Spanned for Values { /// - [Statement::Declare] /// - [Statement::CreateExtension] /// - [Statement::CreateCollation] +/// - [Statement::CreateTextSearchConfiguration] +/// - [Statement::CreateTextSearchDictionary] +/// - [Statement::CreateTextSearchParser] +/// - [Statement::CreateTextSearchTemplate] /// - [Statement::AlterCollation] /// - [Statement::Fetch] /// - [Statement::Flush] @@ -380,6 +384,10 @@ impl Spanned for Statement { Statement::CreateRole(create_role) => create_role.span(), Statement::CreateExtension(create_extension) => create_extension.span(), Statement::CreateCollation(create_collation) => create_collation.span(), + Statement::CreateTextSearchConfiguration(_) => Span::empty(), + Statement::CreateTextSearchDictionary(_) => Span::empty(), + Statement::CreateTextSearchParser(_) => Span::empty(), + Statement::CreateTextSearchTemplate(_) => Span::empty(), Statement::DropExtension(drop_extension) => drop_extension.span(), Statement::DropOperator(drop_operator) => drop_operator.span(), Statement::DropOperatorFamily(drop_operator_family) => drop_operator_family.span(), diff --git a/src/keywords.rs b/src/keywords.rs index 808e5f03d8..6d79fac0e3 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -244,6 +244,7 @@ define_keywords!( COMPRESSION, COMPUPDATE, COMPUTE, + CONFIGURATION, CONCURRENTLY, CONDITION, CONFLICT, @@ -333,6 +334,7 @@ define_keywords!( DETACH, DETAIL, DETERMINISTIC, + DICTIONARY, DIMENSIONS, DIRECTORY, DISABLE, @@ -765,6 +767,7 @@ define_keywords!( PARALLEL, PARAMETER, PARQUET, + PARSER, PART, PARTIAL, PARTITION, @@ -1035,6 +1038,7 @@ define_keywords!( TASK, TBLPROPERTIES, TEMP, + TEMPLATE, TEMPORARY, TEMPTABLE, TERMINATED, From 8ecb6c9be898dc0b55bb37850b4d9ea3a0fc8529 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:22:02 +0900 Subject: [PATCH 16/30] feat(parser): parse CREATE FOREIGN DATA WRAPPER and CREATE FOREIGN TABLE - Dispatch on FOREIGN keyword in parse_create, branching on DATA WRAPPER or TABLE - parse_create_foreign_data_wrapper: parses optional HANDLER/NO HANDLER, VALIDATOR/NO VALIDATOR, and OPTIONS clauses - parse_create_foreign_table: parses IF NOT EXISTS, column list via parse_columns, required SERVER name, and optional OPTIONS clause --- src/parser/mod.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4d6a5e6b36..b9859f60c9 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5213,6 +5213,17 @@ impl<'a> Parser<'a> { } } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() + } else if self.parse_keyword(Keyword::FOREIGN) { + if self.parse_keywords(&[Keyword::DATA, Keyword::WRAPPER]) { + self.parse_create_foreign_data_wrapper().map(Into::into) + } else if self.parse_keyword(Keyword::TABLE) { + self.parse_create_foreign_table().map(Into::into) + } else { + self.expected_ref( + "DATA WRAPPER or TABLE after CREATE FOREIGN", + self.peek_token_ref(), + ) + } } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -19757,6 +19768,86 @@ impl<'a> Parser<'a> { })) } + /// Parse a `CREATE FOREIGN DATA WRAPPER` statement. + /// + /// See + pub fn parse_create_foreign_data_wrapper( + &mut self, + ) -> Result { + let name = self.parse_identifier()?; + + let handler = if self.parse_keyword(Keyword::HANDLER) { + Some(FdwRoutineClause::Function(self.parse_object_name(false)?)) + } else if self.parse_keywords(&[Keyword::NO, Keyword::HANDLER]) { + Some(FdwRoutineClause::NoFunction) + } else { + None + }; + + let validator = if self.parse_keyword(Keyword::VALIDATOR) { + Some(FdwRoutineClause::Function(self.parse_object_name(false)?)) + } else if self.parse_keywords(&[Keyword::NO, Keyword::VALIDATOR]) { + Some(FdwRoutineClause::NoFunction) + } else { + None + }; + + let options = if self.parse_keyword(Keyword::OPTIONS) { + self.expect_token(&Token::LParen)?; + let opts = self.parse_comma_separated(|p| { + let key = p.parse_identifier()?; + let value = p.parse_identifier()?; + Ok(CreateServerOption { key, value }) + })?; + self.expect_token(&Token::RParen)?; + Some(opts) + } else { + None + }; + + Ok(CreateForeignDataWrapper { + name, + handler, + validator, + options, + }) + } + + /// Parse a `CREATE FOREIGN TABLE` statement. + /// + /// See + pub fn parse_create_foreign_table( + &mut self, + ) -> Result { + let if_not_exists = + self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + let (columns, _constraints) = self.parse_columns()?; + self.expect_keyword_is(Keyword::SERVER)?; + let server_name = self.parse_identifier()?; + + let options = if self.parse_keyword(Keyword::OPTIONS) { + self.expect_token(&Token::LParen)?; + let opts = self.parse_comma_separated(|p| { + let key = p.parse_identifier()?; + let value = p.parse_identifier()?; + Ok(CreateServerOption { key, value }) + })?; + self.expect_token(&Token::RParen)?; + Some(opts) + } else { + None + }; + + Ok(CreateForeignTable { + name, + if_not_exists, + columns, + server_name, + options, + }) + } + /// The index of the first unprocessed token. pub fn index(&self) -> usize { self.index From 82d6848bae48ff4313f2603bf23ebf012bec16be Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:22:04 +0900 Subject: [PATCH 17/30] feat(parser): parse CREATE TEXT SEARCH statements --- src/parser/mod.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4d6a5e6b36..13a073d744 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5213,6 +5213,8 @@ impl<'a> Parser<'a> { } } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() + } else if self.parse_keywords(&[Keyword::TEXT, Keyword::SEARCH]) { + self.parse_create_text_search() } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -8177,6 +8179,49 @@ impl<'a> Parser<'a> { }) } + /// Parse a PostgreSQL-specific `CREATE TEXT SEARCH CONFIGURATION | DICTIONARY | PARSER | TEMPLATE` statement. + pub fn parse_create_text_search(&mut self) -> Result { + if self.parse_keyword(Keyword::CONFIGURATION) { + let name = self.parse_object_name(false)?; + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_sql_option)?; + self.expect_token(&Token::RParen)?; + Ok(Statement::CreateTextSearchConfiguration( + CreateTextSearchConfiguration { name, options }, + )) + } else if self.parse_keyword(Keyword::DICTIONARY) { + let name = self.parse_object_name(false)?; + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_sql_option)?; + self.expect_token(&Token::RParen)?; + Ok(Statement::CreateTextSearchDictionary( + CreateTextSearchDictionary { name, options }, + )) + } else if self.parse_keyword(Keyword::PARSER) { + let name = self.parse_object_name(false)?; + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_sql_option)?; + self.expect_token(&Token::RParen)?; + Ok(Statement::CreateTextSearchParser(CreateTextSearchParser { + name, + options, + })) + } else if self.parse_keyword(Keyword::TEMPLATE) { + let name = self.parse_object_name(false)?; + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_sql_option)?; + self.expect_token(&Token::RParen)?; + Ok(Statement::CreateTextSearchTemplate( + CreateTextSearchTemplate { name, options }, + )) + } else { + self.expected_ref( + "CONFIGURATION, DICTIONARY, PARSER, or TEMPLATE after CREATE TEXT SEARCH", + self.peek_token_ref(), + ) + } + } + /// Parse a PostgreSQL-specific [Statement::DropExtension] statement. pub fn parse_drop_extension(&mut self) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); From 7688828a9d0698d7aacd601543d818cb6425e82d Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:22:08 +0900 Subject: [PATCH 18/30] test: add CREATE TEXT SEARCH round-trip tests --- tests/sqlparser_postgres.rs | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 2f6d48ea46..bbc63b7ad8 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -947,6 +947,138 @@ fn parse_alter_collation() { ); } +#[test] +fn parse_create_text_search_configuration() { + assert_eq!( + pg().verified_stmt( + "CREATE TEXT SEARCH CONFIGURATION public.myconfig (PARSER = myparser)" + ), + Statement::CreateTextSearchConfiguration(CreateTextSearchConfiguration { + name: ObjectName::from(vec![Ident::new("public"), Ident::new("myconfig")]), + options: vec![SqlOption::KeyValue { + key: Ident::new("PARSER"), + value: Expr::Identifier(Ident::new("myparser")), + }], + }) + ); + + assert_eq!( + pg().parse_sql_statements("CREATE TEXT SEARCH CONFIGURATION myconfig PARSER = pg_catalog.default"), + Err(ParserError::ParserError( + "Expected: (, found: PARSER".to_string() + )) + ); +} + +#[test] +fn parse_create_text_search_dictionary() { + assert_eq!( + pg().verified_stmt( + "CREATE TEXT SEARCH DICTIONARY public.mydict (TEMPLATE = snowball, language = english)" + ), + Statement::CreateTextSearchDictionary(CreateTextSearchDictionary { + name: ObjectName::from(vec![Ident::new("public"), Ident::new("mydict")]), + options: vec![ + SqlOption::KeyValue { + key: Ident::new("TEMPLATE"), + value: Expr::Identifier(Ident::new("snowball")), + }, + SqlOption::KeyValue { + key: Ident::new("language"), + value: Expr::Identifier(Ident::new("english")), + }, + ], + }) + ); + + assert_eq!( + pg().parse_sql_statements("CREATE TEXT SEARCH DICTIONARY mydict"), + Err(ParserError::ParserError( + "Expected: (, found: EOF".to_string() + )) + ); +} + +#[test] +fn parse_create_text_search_parser() { + assert_eq!( + pg().verified_stmt( + "CREATE TEXT SEARCH PARSER myparser (START = prsd_start, GETTOKEN = prsd_nexttoken, END = prsd_end, LEXTYPES = prsd_lextype, HEADLINE = prsd_headline)" + ), + Statement::CreateTextSearchParser(CreateTextSearchParser { + name: ObjectName::from(vec![Ident::new("myparser")]), + options: vec![ + SqlOption::KeyValue { + key: Ident::new("START"), + value: Expr::Identifier(Ident::new("prsd_start")), + }, + SqlOption::KeyValue { + key: Ident::new("GETTOKEN"), + value: Expr::Identifier(Ident::new("prsd_nexttoken")), + }, + SqlOption::KeyValue { + key: Ident::new("END"), + value: Expr::Identifier(Ident::new("prsd_end")), + }, + SqlOption::KeyValue { + key: Ident::new("LEXTYPES"), + value: Expr::Identifier(Ident::new("prsd_lextype")), + }, + SqlOption::KeyValue { + key: Ident::new("HEADLINE"), + value: Expr::Identifier(Ident::new("prsd_headline")), + }, + ], + }) + ); + + assert_eq!( + pg().parse_sql_statements("CREATE TEXT SEARCH PARSER myparser START = prsd_start"), + Err(ParserError::ParserError( + "Expected: (, found: START".to_string() + )) + ); +} + +#[test] +fn parse_create_text_search_template() { + assert_eq!( + pg().verified_stmt( + "CREATE TEXT SEARCH TEMPLATE mytemplate (INIT = dinit, LEXIZE = dlexize)" + ), + Statement::CreateTextSearchTemplate(CreateTextSearchTemplate { + name: ObjectName::from(vec![Ident::new("mytemplate")]), + options: vec![ + SqlOption::KeyValue { + key: Ident::new("INIT"), + value: Expr::Identifier(Ident::new("dinit")), + }, + SqlOption::KeyValue { + key: Ident::new("LEXIZE"), + value: Expr::Identifier(Ident::new("dlexize")), + }, + ], + }) + ); + + assert_eq!( + pg().parse_sql_statements("CREATE TEXT SEARCH TEMPLATE mytemplate LEXIZE = dlexize"), + Err(ParserError::ParserError( + "Expected: (, found: LEXIZE".to_string() + )) + ); +} + +#[test] +fn parse_create_text_search_invalid_subtype() { + assert_eq!( + pg().parse_sql_statements("CREATE TEXT SEARCH UNKNOWN myname (option = value)"), + Err(ParserError::ParserError( + "Expected: CONFIGURATION, DICTIONARY, PARSER, or TEMPLATE after CREATE TEXT SEARCH, found: UNKNOWN".to_string() + )) + ); +} + #[test] fn parse_drop_and_comment_collation_ast() { assert_eq!( From ef2ce4146cfb111e9f569697367f038be3fab72c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:22:08 +0900 Subject: [PATCH 19/30] test: add CREATE FOREIGN DATA WRAPPER and CREATE FOREIGN TABLE tests Round-trip tests via pg().verified_stmt for: - FDW: name-only, HANDLER, NO HANDLER, NO VALIDATOR, combined HANDLER+VALIDATOR+OPTIONS - FOREIGN TABLE: basic columns+SERVER, IF NOT EXISTS, table-level OPTIONS --- tests/sqlparser_postgres.rs | 96 +++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 2f6d48ea46..2adc8ff1f0 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9327,3 +9327,99 @@ fn parse_lock_table() { } } } + +#[test] +fn parse_create_foreign_data_wrapper() { + // Minimal: name only. + let sql = "CREATE FOREIGN DATA WRAPPER myfdw"; + let Statement::CreateForeignDataWrapper(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "myfdw"); + assert!(stmt.handler.is_none()); + assert!(stmt.validator.is_none()); + assert!(stmt.options.is_none()); + + // With HANDLER. + let sql = "CREATE FOREIGN DATA WRAPPER myfdw HANDLER myhandler"; + let Statement::CreateForeignDataWrapper(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + stmt.handler, + Some(FdwRoutineClause::Function(ObjectName::from(vec![ + "myhandler".into() + ]))) + ); + + // With NO HANDLER. + let sql = "CREATE FOREIGN DATA WRAPPER myfdw NO HANDLER"; + let Statement::CreateForeignDataWrapper(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.handler, Some(FdwRoutineClause::NoFunction)); + + // With NO VALIDATOR. + let sql = "CREATE FOREIGN DATA WRAPPER myfdw NO VALIDATOR"; + let Statement::CreateForeignDataWrapper(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.validator, Some(FdwRoutineClause::NoFunction)); + + // With HANDLER, VALIDATOR, and OPTIONS. + let sql = "CREATE FOREIGN DATA WRAPPER myfdw HANDLER myhandler VALIDATOR myvalidator OPTIONS (debug 'true')"; + let Statement::CreateForeignDataWrapper(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + stmt.handler, + Some(FdwRoutineClause::Function(ObjectName::from(vec![ + "myhandler".into() + ]))) + ); + assert_eq!( + stmt.validator, + Some(FdwRoutineClause::Function(ObjectName::from(vec![ + "myvalidator".into() + ]))) + ); + let options = stmt.options.unwrap(); + assert_eq!(options.len(), 1); + assert_eq!(options[0].key.value, "debug"); + assert_eq!(options[0].value.value, "true"); +} + +#[test] +fn parse_create_foreign_table() { + // Basic: columns and SERVER. + let sql = "CREATE FOREIGN TABLE ft1 (id INTEGER, name TEXT) SERVER myserver"; + let Statement::CreateForeignTable(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.to_string(), "ft1"); + assert!(!stmt.if_not_exists); + assert_eq!(stmt.columns.len(), 2); + assert_eq!(stmt.columns[0].name.value, "id"); + assert_eq!(stmt.columns[1].name.value, "name"); + assert_eq!(stmt.server_name.value, "myserver"); + assert!(stmt.options.is_none()); + + // With IF NOT EXISTS. + let sql = "CREATE FOREIGN TABLE IF NOT EXISTS ft2 (col INTEGER) SERVER remoteserver"; + let Statement::CreateForeignTable(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(stmt.if_not_exists); + assert_eq!(stmt.name.to_string(), "ft2"); + + // With table-level OPTIONS. + let sql = + "CREATE FOREIGN TABLE ft3 (col INTEGER) SERVER remoteserver OPTIONS (schema_name 'public')"; + let Statement::CreateForeignTable(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + let options = stmt.options.unwrap(); + assert_eq!(options.len(), 1); + assert_eq!(options[0].key.value, "schema_name"); + assert_eq!(options[0].value.value, "public"); +} From 6774b8a4073850211ec61dc8d31cc9d850941519 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:24:05 +0900 Subject: [PATCH 20/30] feat(ast): add CreateAggregate type Add Statement::CreateAggregate, CreateAggregate struct, CreateAggregateOption enum, and AggregateModifyKind enum to represent PostgreSQL CREATE AGGREGATE DDL. Options are stored as a typed enum covering all documented parameters (SFUNC, STYPE, FINALFUNC, PARALLEL, moving-aggregate variants, etc.). --- src/ast/ddl.rs | 153 +++++++++++++++++++++++++++++++++++++++++++++++++ src/ast/mod.rs | 15 +++-- 2 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index ad46d2f592..608d0d0b1b 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -5757,3 +5757,156 @@ impl From for crate::ast::Statement { crate::ast::Statement::AlterPolicy(v) } } + +/// CREATE AGGREGATE statement. +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateAggregate { + /// True if `OR REPLACE` was specified. + pub or_replace: bool, + /// The aggregate name (can be schema-qualified). + pub name: ObjectName, + /// Input argument types. Empty for zero-argument aggregates. + pub args: Vec, + /// The options listed inside the required parentheses after the argument + /// list (e.g. `SFUNC`, `STYPE`, `FINALFUNC`, `PARALLEL`, …). + pub options: Vec, +} + +impl fmt::Display for CreateAggregate { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE")?; + if self.or_replace { + write!(f, " OR REPLACE")?; + } + write!(f, " AGGREGATE {}", self.name)?; + write!(f, " ({})", display_comma_separated(&self.args))?; + write!(f, " (")?; + for (i, option) in self.options.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{option}")?; + } + write!(f, ")") + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateAggregate) -> Self { + crate::ast::Statement::CreateAggregate(v) + } +} + +/// A single option in a `CREATE AGGREGATE` options list. +/// +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum CreateAggregateOption { + /// `SFUNC = state_transition_function` + Sfunc(ObjectName), + /// `STYPE = state_data_type` + Stype(DataType), + /// `SSPACE = state_data_size` (in bytes) + Sspace(u64), + /// `FINALFUNC = final_function` + Finalfunc(ObjectName), + /// `FINALFUNC_EXTRA` — pass extra dummy arguments to the final function. + FinalfuncExtra, + /// `FINALFUNC_MODIFY = { READ_ONLY | SHAREABLE | READ_WRITE }` + FinalfuncModify(AggregateModifyKind), + /// `COMBINEFUNC = combine_function` + Combinefunc(ObjectName), + /// `SERIALFUNC = serial_function` + Serialfunc(ObjectName), + /// `DESERIALFUNC = deserial_function` + Deserialfunc(ObjectName), + /// `INITCOND = initial_condition` (a string literal) + Initcond(Value), + /// `MSFUNC = moving_state_transition_function` + Msfunc(ObjectName), + /// `MINVFUNC = moving_inverse_transition_function` + Minvfunc(ObjectName), + /// `MSTYPE = moving_state_data_type` + Mstype(DataType), + /// `MSSPACE = moving_state_data_size` (in bytes) + Msspace(u64), + /// `MFINALFUNC = moving_final_function` + Mfinalfunc(ObjectName), + /// `MFINALFUNC_EXTRA` + MfinalfuncExtra, + /// `MFINALFUNC_MODIFY = { READ_ONLY | SHAREABLE | READ_WRITE }` + MfinalfuncModify(AggregateModifyKind), + /// `MINITCOND = moving_initial_condition` (a string literal) + Minitcond(Value), + /// `SORTOP = sort_operator` + Sortop(ObjectName), + /// `PARALLEL = { SAFE | RESTRICTED | UNSAFE }` + Parallel(FunctionParallel), + /// `HYPOTHETICAL` — marks the aggregate as hypothetical-set. + Hypothetical, +} + +impl fmt::Display for CreateAggregateOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Sfunc(name) => write!(f, "SFUNC = {name}"), + Self::Stype(data_type) => write!(f, "STYPE = {data_type}"), + Self::Sspace(size) => write!(f, "SSPACE = {size}"), + Self::Finalfunc(name) => write!(f, "FINALFUNC = {name}"), + Self::FinalfuncExtra => write!(f, "FINALFUNC_EXTRA"), + Self::FinalfuncModify(kind) => write!(f, "FINALFUNC_MODIFY = {kind}"), + Self::Combinefunc(name) => write!(f, "COMBINEFUNC = {name}"), + Self::Serialfunc(name) => write!(f, "SERIALFUNC = {name}"), + Self::Deserialfunc(name) => write!(f, "DESERIALFUNC = {name}"), + Self::Initcond(cond) => write!(f, "INITCOND = {cond}"), + Self::Msfunc(name) => write!(f, "MSFUNC = {name}"), + Self::Minvfunc(name) => write!(f, "MINVFUNC = {name}"), + Self::Mstype(data_type) => write!(f, "MSTYPE = {data_type}"), + Self::Msspace(size) => write!(f, "MSSPACE = {size}"), + Self::Mfinalfunc(name) => write!(f, "MFINALFUNC = {name}"), + Self::MfinalfuncExtra => write!(f, "MFINALFUNC_EXTRA"), + Self::MfinalfuncModify(kind) => write!(f, "MFINALFUNC_MODIFY = {kind}"), + Self::Minitcond(cond) => write!(f, "MINITCOND = {cond}"), + Self::Sortop(name) => write!(f, "SORTOP = {name}"), + Self::Parallel(parallel) => { + let kind = match parallel { + FunctionParallel::Safe => "SAFE", + FunctionParallel::Restricted => "RESTRICTED", + FunctionParallel::Unsafe => "UNSAFE", + }; + write!(f, "PARALLEL = {kind}") + } + Self::Hypothetical => write!(f, "HYPOTHETICAL"), + } + } +} + +/// Modifier kind for `FINALFUNC_MODIFY` / `MFINALFUNC_MODIFY` in `CREATE AGGREGATE`. +/// +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AggregateModifyKind { + /// The final function does not modify the transition state. + ReadOnly, + /// The transition state may be shared between aggregate calls. + Shareable, + /// The final function may modify the transition state. + ReadWrite, +} + +impl fmt::Display for AggregateModifyKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::ReadOnly => write!(f, "READ_ONLY"), + Self::Shareable => write!(f, "SHAREABLE"), + Self::ReadWrite => write!(f, "READ_WRITE"), + } + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 357b24f9c5..9bd708c9c3 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -67,10 +67,11 @@ pub use self::ddl::{ AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, - ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, - ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, - CreateConnector, CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, - CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, + AggregateModifyKind, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, + ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateAggregate, + CreateAggregateOption, CreateCollation, CreateCollationDefinition, CreateConnector, + CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, + CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, ForValues, FunctionReturnType, GeneratedAs, @@ -3724,6 +3725,11 @@ pub enum Statement { /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createopclass.html) CreateOperatorClass(CreateOperatorClass), /// ```sql + /// CREATE AGGREGATE + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-createaggregate.html) + CreateAggregate(CreateAggregate), + /// ```sql /// ALTER TABLE /// ``` AlterTable(AlterTable), @@ -5511,6 +5517,7 @@ impl fmt::Display for Statement { create_operator_family.fmt(f) } Statement::CreateOperatorClass(create_operator_class) => create_operator_class.fmt(f), + Statement::CreateAggregate(create_aggregate) => create_aggregate.fmt(f), Statement::AlterTable(alter_table) => write!(f, "{alter_table}"), Statement::AlterIndex { name, operation } => { write!(f, "ALTER INDEX {name} {operation}") From 7f7ac31fed009438af148f38a18032251cdebb6c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:24:13 +0900 Subject: [PATCH 21/30] feat(parser): parse CREATE AGGREGATE Wire AGGREGATE into the CREATE dispatch (before the or_replace error branch so CREATE OR REPLACE AGGREGATE is accepted). parse_create_aggregate parses the name, argument-type list, and the options block. Each recognised option keyword dispatches to parse_create_aggregate_option which produces the typed CreateAggregateOption variant. --- src/parser/mod.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4d6a5e6b36..acbeca9877 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5173,6 +5173,8 @@ impl<'a> Parser<'a> { self.parse_create_secret(or_replace, temporary, persistent) } else if self.parse_keyword(Keyword::USER) { self.parse_create_user(or_replace).map(Into::into) + } else if self.parse_keyword(Keyword::AGGREGATE) { + self.parse_create_aggregate(or_replace).map(Into::into) } else if or_replace { self.expected_ref( "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION after CREATE OR REPLACE", @@ -7210,6 +7212,194 @@ impl<'a> Parser<'a> { }) } + /// Parse a [Statement::CreateAggregate] + /// + /// [PostgreSQL Documentation](https://www.postgresql.org/docs/current/sql-createaggregate.html) + pub fn parse_create_aggregate( + &mut self, + or_replace: bool, + ) -> Result { + let name = self.parse_object_name(false)?; + + // Argument type list: `(input_data_type [, ...])` or `(*)` for zero-arg. + self.expect_token(&Token::LParen)?; + let args = if self.consume_token(&Token::Mul) { + // zero-argument aggregate written as `(*)` — treat as empty arg list. + vec![] + } else if self.consume_token(&Token::RParen) { + self.prev_token(); + vec![] + } else { + let parsed = self.parse_comma_separated(|p| p.parse_data_type())?; + parsed + }; + self.expect_token(&Token::RParen)?; + + // Options block: `( SFUNC = ..., STYPE = ..., ... )` + self.expect_token(&Token::LParen)?; + let mut options: Vec = Vec::new(); + loop { + let token = self.next_token(); + match &token.token { + Token::RParen => break, + Token::Comma => continue, + Token::Word(word) => { + let option = self.parse_create_aggregate_option(&word.value.to_uppercase())?; + options.push(option); + } + other => { + return Err(ParserError::ParserError(format!( + "Unexpected token in CREATE AGGREGATE options: {other:?}" + ))); + } + } + } + + Ok(CreateAggregate { + or_replace, + name, + args, + options, + }) + } + + fn parse_create_aggregate_option( + &mut self, + key: &str, + ) -> Result { + match key { + "SFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Sfunc( + self.parse_object_name(false)?, + )) + } + "STYPE" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Stype(self.parse_data_type()?)) + } + "SSPACE" => { + self.expect_token(&Token::Eq)?; + let size = self.parse_literal_uint()?; + Ok(CreateAggregateOption::Sspace(size)) + } + "FINALFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Finalfunc( + self.parse_object_name(false)?, + )) + } + "FINALFUNC_EXTRA" => Ok(CreateAggregateOption::FinalfuncExtra), + "FINALFUNC_MODIFY" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::FinalfuncModify( + self.parse_aggregate_modify_kind()?, + )) + } + "COMBINEFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Combinefunc( + self.parse_object_name(false)?, + )) + } + "SERIALFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Serialfunc( + self.parse_object_name(false)?, + )) + } + "DESERIALFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Deserialfunc( + self.parse_object_name(false)?, + )) + } + "INITCOND" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Initcond(self.parse_value()?.value)) + } + "MSFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Msfunc( + self.parse_object_name(false)?, + )) + } + "MINVFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Minvfunc( + self.parse_object_name(false)?, + )) + } + "MSTYPE" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Mstype(self.parse_data_type()?)) + } + "MSSPACE" => { + self.expect_token(&Token::Eq)?; + let size = self.parse_literal_uint()?; + Ok(CreateAggregateOption::Msspace(size)) + } + "MFINALFUNC" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Mfinalfunc( + self.parse_object_name(false)?, + )) + } + "MFINALFUNC_EXTRA" => Ok(CreateAggregateOption::MfinalfuncExtra), + "MFINALFUNC_MODIFY" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::MfinalfuncModify( + self.parse_aggregate_modify_kind()?, + )) + } + "MINITCOND" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Minitcond(self.parse_value()?.value)) + } + "SORTOP" => { + self.expect_token(&Token::Eq)?; + Ok(CreateAggregateOption::Sortop( + self.parse_object_name(false)?, + )) + } + "PARALLEL" => { + self.expect_token(&Token::Eq)?; + let parallel = match self.expect_one_of_keywords(&[ + Keyword::SAFE, + Keyword::RESTRICTED, + Keyword::UNSAFE, + ])? { + Keyword::SAFE => FunctionParallel::Safe, + Keyword::RESTRICTED => FunctionParallel::Restricted, + Keyword::UNSAFE => FunctionParallel::Unsafe, + _ => unreachable!(), + }; + Ok(CreateAggregateOption::Parallel(parallel)) + } + "HYPOTHETICAL" => Ok(CreateAggregateOption::Hypothetical), + other => Err(ParserError::ParserError(format!( + "Unknown CREATE AGGREGATE option: {other}" + ))), + } + } + + fn parse_aggregate_modify_kind(&mut self) -> Result { + let token = self.next_token(); + match &token.token { + Token::Word(word) => match word.value.to_uppercase().as_str() { + "READ_ONLY" => Ok(AggregateModifyKind::ReadOnly), + "SHAREABLE" => Ok(AggregateModifyKind::Shareable), + "READ_WRITE" => Ok(AggregateModifyKind::ReadWrite), + other => Err(ParserError::ParserError(format!( + "Expected READ_ONLY, SHAREABLE, or READ_WRITE, got: {other}" + ))), + }, + other => Err(ParserError::ParserError(format!( + "Expected READ_ONLY, SHAREABLE, or READ_WRITE, got: {other:?}" + ))), + } + } + /// Parse a [Statement::CreateOperatorFamily] /// /// [PostgreSQL Documentation](https://www.postgresql.org/docs/current/sql-createopfamily.html) From aff2e815721990a0c3a447e7de6f2b2ac6ede69b Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 17 Apr 2026 11:24:20 +0900 Subject: [PATCH 22/30] test: add CREATE AGGREGATE round-trip tests Three tests covering: basic old-style aggregate (SFUNC/STYPE/FINALFUNC/INITCOND), CREATE OR REPLACE with PARALLEL = SAFE, and moving-aggregate options (MSFUNC/MINVFUNC/MSTYPE/MFINALFUNC_EXTRA/MFINALFUNC_MODIFY). All use pg().verified_stmt() to assert parse-then-display round-trips identically. --- tests/sqlparser_postgres.rs | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 2f6d48ea46..dcf0d33014 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9327,3 +9327,63 @@ fn parse_lock_table() { } } } + +#[test] +fn parse_create_aggregate_basic() { + let sql = "CREATE AGGREGATE myavg (NUMERIC) (SFUNC = numeric_avg_accum, STYPE = internal, FINALFUNC = numeric_avg, INITCOND = '0')"; + let stmt = pg().verified_stmt(sql); + match stmt { + Statement::CreateAggregate(agg) => { + assert!(!agg.or_replace); + assert_eq!(agg.name.to_string(), "myavg"); + assert_eq!(agg.args.len(), 1); + assert_eq!(agg.args[0].to_string(), "NUMERIC"); + assert_eq!(agg.options.len(), 4); + assert_eq!( + agg.options[0].to_string(), + "SFUNC = numeric_avg_accum" + ); + assert_eq!(agg.options[1].to_string(), "STYPE = internal"); + assert_eq!(agg.options[2].to_string(), "FINALFUNC = numeric_avg"); + assert_eq!(agg.options[3].to_string(), "INITCOND = '0'"); + } + _ => panic!("Expected CreateAggregate, got: {stmt:?}"), + } +} + +#[test] +fn parse_create_aggregate_or_replace_with_parallel() { + let sql = "CREATE OR REPLACE AGGREGATE sum2 (INT4, INT4) (SFUNC = int4pl, STYPE = INT4, PARALLEL = SAFE)"; + let stmt = pg().verified_stmt(sql); + match stmt { + Statement::CreateAggregate(agg) => { + assert!(agg.or_replace); + assert_eq!(agg.name.to_string(), "sum2"); + assert_eq!(agg.args.len(), 2); + assert_eq!(agg.options.len(), 3); + assert_eq!(agg.options[2].to_string(), "PARALLEL = SAFE"); + } + _ => panic!("Expected CreateAggregate, got: {stmt:?}"), + } +} + +#[test] +fn parse_create_aggregate_with_moving_aggregate_options() { + let sql = "CREATE AGGREGATE moving_sum (FLOAT8) (SFUNC = float8pl, STYPE = FLOAT8, MSFUNC = float8pl, MINVFUNC = float8mi, MSTYPE = FLOAT8, MFINALFUNC_EXTRA, MFINALFUNC_MODIFY = READ_ONLY)"; + let stmt = pg().verified_stmt(sql); + match stmt { + Statement::CreateAggregate(agg) => { + assert!(!agg.or_replace); + assert_eq!(agg.name.to_string(), "moving_sum"); + assert_eq!(agg.args.len(), 1); + assert_eq!(agg.options.len(), 7); + assert_eq!(agg.options[4].to_string(), "MSTYPE = FLOAT8"); + assert_eq!(agg.options[5].to_string(), "MFINALFUNC_EXTRA"); + assert_eq!( + agg.options[6].to_string(), + "MFINALFUNC_MODIFY = READ_ONLY" + ); + } + _ => panic!("Expected CreateAggregate, got: {stmt:?}"), + } +} From 1fb2b6b78b46b576e0111f19e848e349b73db204 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 09:58:31 +0900 Subject: [PATCH 23/30] chore: bump version to 0.60.8 --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 000465aa15..9c6ffb078d 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)" -version = "0.60.7" +description = "Fork of sqlparser with additional PostgreSQL features (PARTITION OF, SECURITY DEFINER/INVOKER, SET params, EXCLUDE, TEXT SEARCH, AGGREGATE, FOREIGN TABLE/FDW)" +version = "0.60.8" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" From 3b358095a0ed2bc8908d4053bd56700a5434bf4e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 10:00:28 +0900 Subject: [PATCH 24/30] fix(spans): add Span::empty() arm for CreateAggregate PR #7 added the Statement::CreateAggregate variant but omitted the corresponding match arm in the Spanned impl for Statement. Fork CI never ran on the PR so this was not caught before merge. --- src/ast/spans.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index b973dfcdae..9fb45018f0 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -521,6 +521,7 @@ impl Spanned for Statement { Statement::Vacuum(..) => Span::empty(), Statement::AlterUser(..) => Span::empty(), Statement::Reset(..) => Span::empty(), + Statement::CreateAggregate(_) => Span::empty(), } } } From 35160e8267c3be26574503f174594a06f18dadd7 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:09:32 +0900 Subject: [PATCH 25/30] feat(ast): add CreatePublication and CreateSubscription AST nodes Add PublicationTarget enum (AllTables, Tables, TablesInSchema), CreatePublication struct, and CreateSubscription struct to ddl.rs. Add Statement variants, Display arms, and re-exports in mod.rs. Add Span::empty() arms in spans.rs. Add PUBLICATION and SUBSCRIPTION to keywords.rs. --- src/ast/ddl.rs | 108 ++++++++++++++++++++++++++++++++++++ src/ast/mod.rs | 19 ++++++- src/ast/spans.rs | 2 + src/keywords.rs | 2 + src/parser/mod.rs | 57 +++++++++++++++++++ tests/sqlparser_postgres.rs | 106 +++++++++++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 2 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 722d99911e..e0f203a8d5 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -6136,3 +6136,111 @@ impl From for crate::ast::Statement { crate::ast::Statement::CreateTextSearchTemplate(v) } } + +/// The target of a `CREATE PUBLICATION` statement: which rows to publish. +/// +/// See +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum PublicationTarget { + /// `FOR ALL TABLES` + AllTables, + /// `FOR TABLE table [, ...]` + Tables(Vec), + /// `FOR TABLES IN SCHEMA schema [, ...]` + TablesInSchema(Vec), +} + +impl fmt::Display for PublicationTarget { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PublicationTarget::AllTables => write!(f, "FOR ALL TABLES"), + PublicationTarget::Tables(tables) => { + write!(f, "FOR TABLE {}", display_comma_separated(tables)) + } + PublicationTarget::TablesInSchema(schemas) => { + write!( + f, + "FOR TABLES IN SCHEMA {}", + display_comma_separated(schemas) + ) + } + } + } +} + +/// A `CREATE PUBLICATION` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreatePublication { + /// The publication name. + pub name: Ident, + /// Optional target specification (`FOR ALL TABLES`, `FOR TABLE ...`, or `FOR TABLES IN SCHEMA ...`). + pub target: Option, + /// Optional `WITH (key = value, ...)` clause. + pub with_options: Vec, +} + +impl fmt::Display for CreatePublication { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE PUBLICATION {}", self.name)?; + if let Some(target) = &self.target { + write!(f, " {target}")?; + } + if !self.with_options.is_empty() { + write!(f, " WITH ({})", display_comma_separated(&self.with_options))?; + } + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreatePublication) -> Self { + crate::ast::Statement::CreatePublication(v) + } +} + +/// A `CREATE SUBSCRIPTION` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateSubscription { + /// The subscription name. + pub name: Ident, + /// The `CONNECTION 'conninfo'` string. + pub connection: Value, + /// The `PUBLICATION publication_name [, ...]` list. + pub publications: Vec, + /// Optional `WITH (key = value, ...)` clause. + pub with_options: Vec, +} + +impl fmt::Display for CreateSubscription { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE SUBSCRIPTION {name} CONNECTION {connection} PUBLICATION {publications}", + name = self.name, + connection = self.connection, + publications = display_comma_separated(&self.publications), + )?; + if !self.with_options.is_empty() { + write!(f, " WITH ({})", display_comma_separated(&self.with_options))?; + } + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateSubscription) -> Self { + crate::ast::Statement::CreateSubscription(v) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3dd716a115..7ad65c3588 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -72,8 +72,9 @@ pub use self::ddl::{ CreateAggregateOption, CreateCollation, CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, CreateForeignDataWrapper, CreateForeignTable, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, - CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTextSearchConfiguration, - CreateTextSearchDictionary, CreateTextSearchParser, CreateTextSearchTemplate, CreateTrigger, + CreatePolicyCommand, CreatePolicyType, CreatePublication, CreateSubscription, CreateTable, + CreateTextSearchConfiguration, CreateTextSearchDictionary, CreateTextSearchParser, + CreateTextSearchTemplate, CreateTrigger, PublicationTarget, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, FdwRoutineClause, ForValues, FunctionReturnType, GeneratedAs, @@ -4017,6 +4018,18 @@ pub enum Statement { /// CreateTextSearchTemplate(CreateTextSearchTemplate), /// ```sql + /// CREATE PUBLICATION name [ FOR ALL TABLES | FOR TABLE table [, ...] | FOR TABLES IN SCHEMA schema [, ...] ] [ WITH ( option = value [, ...] ) ] + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreatePublication(CreatePublication), + /// ```sql + /// CREATE SUBSCRIPTION name CONNECTION 'conninfo' PUBLICATION publication_name [, ...] [ WITH ( option = value [, ...] ) ] + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateSubscription(CreateSubscription), + /// ```sql /// DROP EXTENSION [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] /// ``` /// Note: this is a PostgreSQL-specific statement. @@ -5503,6 +5516,8 @@ impl fmt::Display for Statement { Statement::CreateTextSearchDictionary(v) => write!(f, "{v}"), Statement::CreateTextSearchParser(v) => write!(f, "{v}"), Statement::CreateTextSearchTemplate(v) => write!(f, "{v}"), + Statement::CreatePublication(v) => write!(f, "{v}"), + Statement::CreateSubscription(v) => write!(f, "{v}"), Statement::DropExtension(drop_extension) => write!(f, "{drop_extension}"), Statement::DropOperator(drop_operator) => write!(f, "{drop_operator}"), Statement::DropOperatorFamily(drop_operator_family) => { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 9fb45018f0..f4e56f3f55 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -388,6 +388,8 @@ impl Spanned for Statement { Statement::CreateTextSearchDictionary(_) => Span::empty(), Statement::CreateTextSearchParser(_) => Span::empty(), Statement::CreateTextSearchTemplate(_) => Span::empty(), + Statement::CreatePublication(_) => Span::empty(), + Statement::CreateSubscription(_) => Span::empty(), Statement::DropExtension(drop_extension) => drop_extension.span(), Statement::DropOperator(drop_operator) => drop_operator.span(), Statement::DropOperatorFamily(drop_operator_family) => drop_operator_family.span(), diff --git a/src/keywords.rs b/src/keywords.rs index 346acf1ed2..d86e045695 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -822,6 +822,7 @@ define_keywords!( PROGRAM, PROJECTION, PUBLIC, + PUBLICATION, PURCHASE, PURGE, QUALIFY, @@ -1010,6 +1011,7 @@ define_keywords!( STRUCT, SUBMULTISET, SUBSCRIPT, + SUBSCRIPTION, SUBSTR, SUBSTRING, SUBSTRING_REGEX, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index c0c247aa45..9e193ec578 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5228,6 +5228,10 @@ impl<'a> Parser<'a> { } } else if self.parse_keywords(&[Keyword::TEXT, Keyword::SEARCH]) { self.parse_create_text_search() + } else if self.parse_keyword(Keyword::PUBLICATION) { + self.parse_create_publication().map(Into::into) + } else if self.parse_keyword(Keyword::SUBSCRIPTION) { + self.parse_create_subscription().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -20083,6 +20087,59 @@ impl<'a> Parser<'a> { }) } + /// Parse a `CREATE PUBLICATION` statement. + /// + /// See + pub fn parse_create_publication(&mut self) -> Result { + let name = self.parse_identifier()?; + + let target = if self.parse_keyword(Keyword::FOR) { + if self.parse_keywords(&[Keyword::ALL, Keyword::TABLES]) { + Some(PublicationTarget::AllTables) + } else if self.parse_keyword(Keyword::TABLE) { + let tables = self.parse_comma_separated(|p| p.parse_object_name(false))?; + Some(PublicationTarget::Tables(tables)) + } else if self.parse_keywords(&[Keyword::TABLES, Keyword::IN, Keyword::SCHEMA]) { + let schemas = self.parse_comma_separated(|p| p.parse_identifier())?; + Some(PublicationTarget::TablesInSchema(schemas)) + } else { + return self.expected_ref( + "ALL TABLES, TABLE, or TABLES IN SCHEMA after FOR", + self.peek_token_ref(), + ); + } + } else { + None + }; + + let with_options = self.parse_options(Keyword::WITH)?; + + Ok(CreatePublication { + name, + target, + with_options, + }) + } + + /// Parse a `CREATE SUBSCRIPTION` statement. + /// + /// See + pub fn parse_create_subscription(&mut self) -> Result { + let name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::CONNECTION)?; + let connection = self.parse_value()?.value; + self.expect_keyword_is(Keyword::PUBLICATION)?; + let publications = self.parse_comma_separated(|p| p.parse_identifier())?; + let with_options = self.parse_options(Keyword::WITH)?; + + Ok(CreateSubscription { + name, + connection, + publications, + with_options, + }) + } + /// The index of the first unprocessed token. pub fn index(&self) -> usize { self.index diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 89f164033f..314c4f51d6 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9615,3 +9615,109 @@ fn parse_create_aggregate_with_moving_aggregate_options() { _ => panic!("Expected CreateAggregate, got: {stmt:?}"), } } + +#[test] +fn parse_create_publication_basic() { + let sql = "CREATE PUBLICATION mypub FOR TABLE public.t"; + let Statement::CreatePublication(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mypub"); + assert!(stmt.with_options.is_empty()); + match stmt.target.unwrap() { + PublicationTarget::Tables(tables) => { + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].to_string(), "public.t"); + } + other => panic!("unexpected target: {other:?}"), + } +} + +#[test] +fn parse_create_publication_for_all_tables() { + let sql = "CREATE PUBLICATION mypub FOR ALL TABLES"; + let Statement::CreatePublication(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mypub"); + assert!(matches!(stmt.target, Some(PublicationTarget::AllTables))); + assert!(stmt.with_options.is_empty()); +} + +#[test] +fn parse_create_publication_for_tables_in_schema() { + let sql = "CREATE PUBLICATION mypub FOR TABLES IN SCHEMA myschema"; + let Statement::CreatePublication(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mypub"); + match stmt.target.unwrap() { + PublicationTarget::TablesInSchema(schemas) => { + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].value, "myschema"); + } + other => panic!("unexpected target: {other:?}"), + } +} + +#[test] +fn parse_create_publication_with_options() { + let sql = "CREATE PUBLICATION mypub FOR ALL TABLES WITH (publish = 'insert, update')"; + let Statement::CreatePublication(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mypub"); + assert!(matches!(stmt.target, Some(PublicationTarget::AllTables))); + assert_eq!(stmt.with_options.len(), 1); + match &stmt.with_options[0] { + SqlOption::KeyValue { key, value } => { + assert_eq!(key.value, "publish"); + assert_eq!(value.to_string(), "'insert, update'"); + } + other => panic!("unexpected option: {other:?}"), + } +} + +#[test] +fn parse_create_subscription_basic() { + let sql = "CREATE SUBSCRIPTION mysub CONNECTION 'host=localhost' PUBLICATION mypub"; + let Statement::CreateSubscription(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mysub"); + assert_eq!(stmt.connection.to_string(), "'host=localhost'"); + assert_eq!(stmt.publications.len(), 1); + assert_eq!(stmt.publications[0].value, "mypub"); + assert!(stmt.with_options.is_empty()); +} + +#[test] +fn parse_create_subscription_with_options() { + let sql = "CREATE SUBSCRIPTION mysub CONNECTION 'host=localhost dbname=mydb' PUBLICATION mypub, otherpub WITH (copy_data = true, slot_name = 'myslot')"; + let Statement::CreateSubscription(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "mysub"); + assert_eq!( + stmt.connection.to_string(), + "'host=localhost dbname=mydb'" + ); + assert_eq!(stmt.publications.len(), 2); + assert_eq!(stmt.publications[0].value, "mypub"); + assert_eq!(stmt.publications[1].value, "otherpub"); + assert_eq!(stmt.with_options.len(), 2); + match &stmt.with_options[0] { + SqlOption::KeyValue { key, value } => { + assert_eq!(key.value, "copy_data"); + assert_eq!(value.to_string(), "true"); + } + other => panic!("unexpected option: {other:?}"), + } + match &stmt.with_options[1] { + SqlOption::KeyValue { key, value } => { + assert_eq!(key.value, "slot_name"); + assert_eq!(value.to_string(), "'myslot'"); + } + other => panic!("unexpected option: {other:?}"), + } +} From ccf275622a2647c66603defdf4e30b08eec188a5 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:16:07 +0900 Subject: [PATCH 26/30] feat(ast): add AlterDomain, AlterTrigger, AlterExtension, extend AlterFunctionKind with Procedure, add SetTablespace to AlterTableOperation and AlterIndexOperation Adds AST types and Display impls for: - AlterDomain + AlterDomainOperation (ADD/DROP CONSTRAINT, RENAME, OWNER TO, SET SCHEMA/DEFAULT, DROP DEFAULT, VALIDATE CONSTRAINT) - AlterTrigger + AlterTriggerOperation (RENAME TO) - AlterExtension + AlterExtensionOperation (UPDATE TO, SET SCHEMA, OWNER TO, RENAME TO) - AlterFunctionKind::Procedure variant for ALTER PROCEDURE - AlterTableOperation::SetTablespace - AlterIndexOperation::SetTablespace Updates Spanned impls to remain exhaustive. --- src/ast/ddl.rs | 270 +++++++++++++++++++++++++++++++++++- src/ast/mod.rs | 24 +++- src/ast/spans.rs | 5 + src/parser/mod.rs | 140 ++++++++++++++++++- tests/sqlparser_postgres.rs | 146 +++++++++++++++++++ 5 files changed, 577 insertions(+), 8 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 722d99911e..e792400d8b 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -535,6 +535,14 @@ pub enum AlterTableOperation { /// Parenthesized options supplied to `SET (...)`. options: Vec, }, + /// `SET TABLESPACE tablespace_name` + /// + /// Note: this is a PostgreSQL-specific operation. + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-altertable.html) + SetTablespace { + /// The target tablespace name. + tablespace_name: Ident, + }, } /// An `ALTER Policy` (`Statement::AlterPolicy`) operation @@ -700,6 +708,14 @@ pub enum AlterIndexOperation { /// The new name for the index. index_name: ObjectName, }, + /// `SET TABLESPACE tablespace_name` + /// + /// Note: this is a PostgreSQL-specific operation. + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterindex.html) + SetTablespace { + /// The target tablespace name. + tablespace_name: Ident, + }, } impl fmt::Display for AlterTableOperation { @@ -1045,6 +1061,9 @@ impl fmt::Display for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { write!(f, "SET ({})", display_comma_separated(options)) } + AlterTableOperation::SetTablespace { tablespace_name } => { + write!(f, "SET TABLESPACE {tablespace_name}") + } } } } @@ -1055,6 +1074,9 @@ impl fmt::Display for AlterIndexOperation { AlterIndexOperation::RenameIndex { index_name } => { write!(f, "RENAME TO {index_name}") } + AlterIndexOperation::SetTablespace { tablespace_name } => { + write!(f, "SET TABLESPACE {tablespace_name}") + } } } } @@ -5390,6 +5412,8 @@ pub enum AlterFunctionKind { Function, /// `AGGREGATE` Aggregate, + /// `PROCEDURE` + Procedure, } impl fmt::Display for AlterFunctionKind { @@ -5397,6 +5421,7 @@ impl fmt::Display for AlterFunctionKind { match self { Self::Function => write!(f, "FUNCTION"), Self::Aggregate => write!(f, "AGGREGATE"), + Self::Procedure => write!(f, "PROCEDURE"), } } } @@ -5471,7 +5496,7 @@ impl fmt::Display for AlterFunction { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "ALTER {} ", self.kind)?; match self.kind { - AlterFunctionKind::Function => { + AlterFunctionKind::Function | AlterFunctionKind::Procedure => { write!(f, "{} ", self.function)?; } AlterFunctionKind::Aggregate => { @@ -6136,3 +6161,246 @@ impl From for crate::ast::Statement { crate::ast::Statement::CreateTextSearchTemplate(v) } } + +/// `ALTER DOMAIN` statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterdomain.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 AlterDomain { + /// Name of the domain being altered. + pub name: ObjectName, + /// The operation to perform. + pub operation: AlterDomainOperation, +} + +/// An [AlterDomain] operation. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterDomainOperation { + /// `ADD CONSTRAINT constraint_name CHECK (expr) [NOT VALID]` + AddConstraint { + /// The constraint to add. + constraint: TableConstraint, + /// Whether `NOT VALID` was specified. + not_valid: bool, + }, + /// `DROP CONSTRAINT [IF EXISTS] constraint_name [CASCADE | RESTRICT]` + DropConstraint { + /// Whether `IF EXISTS` was specified. + if_exists: bool, + /// Name of the constraint to drop. + name: Ident, + /// Optional drop behavior. + drop_behavior: Option, + }, + /// `RENAME CONSTRAINT old_name TO new_name` + RenameConstraint { + /// Existing constraint name. + old_name: Ident, + /// New constraint name. + new_name: Ident, + }, + /// `OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER }` + OwnerTo(Owner), + /// `RENAME TO new_name` + RenameTo { + /// New name for the domain. + new_name: Ident, + }, + /// `SET SCHEMA schema_name` + SetSchema { + /// The target schema name. + schema_name: ObjectName, + }, + /// `SET DEFAULT expr` + SetDefault { + /// Default value expression. + default: Expr, + }, + /// `DROP DEFAULT` + DropDefault, + /// `VALIDATE CONSTRAINT constraint_name` + ValidateConstraint { + /// Name of the constraint to validate. + name: Ident, + }, +} + +impl fmt::Display for AlterDomain { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER DOMAIN {} {}", self.name, self.operation) + } +} + +impl fmt::Display for AlterDomainOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterDomainOperation::AddConstraint { + constraint, + not_valid, + } => { + write!(f, "ADD {constraint}")?; + if *not_valid { + write!(f, " NOT VALID")?; + } + Ok(()) + } + AlterDomainOperation::DropConstraint { + if_exists, + name, + drop_behavior, + } => { + write!(f, "DROP CONSTRAINT")?; + if *if_exists { + write!(f, " IF EXISTS")?; + } + write!(f, " {name}")?; + if let Some(behavior) = drop_behavior { + write!(f, " {behavior}")?; + } + Ok(()) + } + AlterDomainOperation::RenameConstraint { old_name, new_name } => { + write!(f, "RENAME CONSTRAINT {old_name} TO {new_name}") + } + AlterDomainOperation::OwnerTo(owner) => write!(f, "OWNER TO {owner}"), + AlterDomainOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + AlterDomainOperation::SetSchema { schema_name } => { + write!(f, "SET SCHEMA {schema_name}") + } + AlterDomainOperation::SetDefault { default } => write!(f, "SET DEFAULT {default}"), + AlterDomainOperation::DropDefault => write!(f, "DROP DEFAULT"), + AlterDomainOperation::ValidateConstraint { name } => { + write!(f, "VALIDATE CONSTRAINT {name}") + } + } + } +} + +impl From for crate::ast::Statement { + fn from(a: AlterDomain) -> Self { + crate::ast::Statement::AlterDomain(a) + } +} + +/// `ALTER TRIGGER name ON table_name RENAME TO new_name` statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-altertrigger.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 AlterTrigger { + /// Name of the trigger being altered. + pub name: Ident, + /// Name of the table the trigger is defined on. + pub table_name: ObjectName, + /// The operation to perform. + pub operation: AlterTriggerOperation, +} + +/// An [AlterTrigger] operation. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterTriggerOperation { + /// `RENAME TO new_name` + RenameTo { + /// New name for the trigger. + new_name: Ident, + }, +} + +impl fmt::Display for AlterTrigger { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "ALTER TRIGGER {} ON {} {}", + self.name, self.table_name, self.operation + ) + } +} + +impl fmt::Display for AlterTriggerOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterTriggerOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + } + } +} + +impl From for crate::ast::Statement { + fn from(a: AlterTrigger) -> Self { + crate::ast::Statement::AlterTrigger(a) + } +} + +/// `ALTER EXTENSION` statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterextension.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 AlterExtension { + /// Name of the extension being altered. + pub name: Ident, + /// The operation to perform. + pub operation: AlterExtensionOperation, +} + +/// An [AlterExtension] operation. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AlterExtensionOperation { + /// `UPDATE [ TO new_version ]` + UpdateTo { + /// Optional target version string or identifier. + version: Option, + }, + /// `SET SCHEMA schema_name` + SetSchema { + /// The target schema name. + schema_name: ObjectName, + }, + /// `OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER }` + OwnerTo(Owner), + /// `RENAME TO new_name` + RenameTo { + /// New name for the extension. + new_name: Ident, + }, +} + +impl fmt::Display for AlterExtension { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "ALTER EXTENSION {} {}", self.name, self.operation) + } +} + +impl fmt::Display for AlterExtensionOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterExtensionOperation::UpdateTo { version } => { + write!(f, "UPDATE")?; + if let Some(v) = version { + write!(f, " TO {v}")?; + } + Ok(()) + } + AlterExtensionOperation::SetSchema { schema_name } => { + write!(f, "SET SCHEMA {schema_name}") + } + AlterExtensionOperation::OwnerTo(owner) => write!(f, "OWNER TO {owner}"), + AlterExtensionOperation::RenameTo { new_name } => write!(f, "RENAME TO {new_name}"), + } + } +} + +impl From for crate::ast::Statement { + fn from(a: AlterExtension) -> Self { + crate::ast::Statement::AlterExtension(a) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 3dd716a115..b35761b3b8 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -61,11 +61,13 @@ pub use self::dcl::{ }; pub use self::ddl::{ Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, + AlterDomain, AlterDomainOperation, AlterExtension, AlterExtensionOperation, AlterFunction, AlterFunctionAction, AlterFunctionKind, AlterFunctionOperation, AlterIndexOperation, AlterOperator, AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, - AlterTableLock, AlterTableOperation, AlterTableType, AlterType, AlterTypeAddValue, + AlterTableLock, AlterTableOperation, AlterTableType, AlterTrigger, AlterTriggerOperation, + AlterType, AlterTypeAddValue, AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, AggregateModifyKind, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateAggregate, @@ -3774,11 +3776,23 @@ pub enum Statement { with_options: Vec, }, /// ```sql + /// ALTER DOMAIN + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterdomain.html) + AlterDomain(AlterDomain), + /// ```sql + /// ALTER EXTENSION + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterextension.html) + AlterExtension(AlterExtension), + /// ```sql /// ALTER FUNCTION /// ALTER AGGREGATE + /// ALTER PROCEDURE /// ``` /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterfunction.html) /// and [PostgreSQL](https://www.postgresql.org/docs/current/sql-alteraggregate.html) + /// and [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterprocedure.html) AlterFunction(AlterFunction), /// ```sql /// ALTER TYPE @@ -3786,6 +3800,11 @@ pub enum Statement { /// ``` AlterType(AlterType), /// ```sql + /// ALTER TRIGGER + /// ``` + /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-altertrigger.html) + AlterTrigger(AlterTrigger), + /// ```sql /// ALTER COLLATION /// ``` /// See [PostgreSQL](https://www.postgresql.org/docs/current/sql-altercollation.html) @@ -5579,7 +5598,10 @@ impl fmt::Display for Statement { } write!(f, " AS {query}") } + Statement::AlterDomain(alter_domain) => write!(f, "{alter_domain}"), + Statement::AlterExtension(alter_extension) => write!(f, "{alter_extension}"), Statement::AlterFunction(alter_function) => write!(f, "{alter_function}"), + Statement::AlterTrigger(alter_trigger) => write!(f, "{alter_trigger}"), Statement::AlterType(AlterType { name, operation }) => { write!(f, "ALTER TYPE {name} {operation}") } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 9fb45018f0..8bf51e0783 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -415,6 +415,9 @@ impl Spanned for Statement { .chain(core::iter::once(query.span())) .chain(with_options.iter().map(|i| i.span())), ), + Statement::AlterDomain(_) => Span::empty(), + Statement::AlterExtension(_) => Span::empty(), + Statement::AlterTrigger(_) => Span::empty(), // These statements need to be implemented Statement::AlterFunction { .. } => Span::empty(), Statement::AlterType { .. } => Span::empty(), @@ -1234,6 +1237,7 @@ impl Spanned for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { union_spans(options.iter().map(|i| i.span())) } + AlterTableOperation::SetTablespace { .. } => Span::empty(), } } } @@ -1320,6 +1324,7 @@ impl Spanned for AlterIndexOperation { fn span(&self) -> Span { match self { AlterIndexOperation::RenameIndex { index_name } => index_name.span(), + AlterIndexOperation::SetTablespace { .. } => Span::empty(), } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index c0c247aa45..92d7167005 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10950,6 +10950,9 @@ impl<'a> Parser<'a> { } else if self.parse_keywords(&[Keyword::VALIDATE, Keyword::CONSTRAINT]) { let name = self.parse_identifier()?; AlterTableOperation::ValidateConstraint { name } + } else if self.parse_keywords(&[Keyword::SET, Keyword::TABLESPACE]) { + let tablespace_name = self.parse_identifier()?; + AlterTableOperation::SetTablespace { tablespace_name } } else { let mut options = self.parse_options_with_keywords(&[Keyword::SET, Keyword::TBLPROPERTIES])?; @@ -11017,6 +11020,10 @@ impl<'a> Parser<'a> { Keyword::SCHEMA, Keyword::USER, Keyword::OPERATOR, + Keyword::DOMAIN, + Keyword::TRIGGER, + Keyword::EXTENSION, + Keyword::PROCEDURE, ])?; match object_type { Keyword::SCHEMA => { @@ -11041,8 +11048,14 @@ impl<'a> Parser<'a> { } else { return self.expected_ref("TO after RENAME", self.peek_token_ref()); } + } else if self.parse_keywords(&[Keyword::SET, Keyword::TABLESPACE]) { + let tablespace_name = self.parse_identifier()?; + AlterIndexOperation::SetTablespace { tablespace_name } } else { - return self.expected_ref("RENAME after ALTER INDEX", self.peek_token_ref()); + return self.expected_ref( + "RENAME or SET TABLESPACE after ALTER INDEX", + self.peek_token_ref(), + ); }; Ok(Statement::AlterIndex { @@ -11052,6 +11065,7 @@ impl<'a> Parser<'a> { } Keyword::FUNCTION => self.parse_alter_function(AlterFunctionKind::Function), Keyword::AGGREGATE => self.parse_alter_function(AlterFunctionKind::Aggregate), + Keyword::PROCEDURE => self.parse_alter_function(AlterFunctionKind::Procedure), Keyword::OPERATOR => { if self.parse_keyword(Keyword::FAMILY) { self.parse_alter_operator_family().map(Into::into) @@ -11065,9 +11079,12 @@ impl<'a> Parser<'a> { Keyword::POLICY => self.parse_alter_policy().map(Into::into), Keyword::CONNECTOR => self.parse_alter_connector(), Keyword::USER => self.parse_alter_user().map(Into::into), + Keyword::DOMAIN => self.parse_alter_domain(), + Keyword::TRIGGER => self.parse_alter_trigger(), + Keyword::EXTENSION => self.parse_alter_extension(), // unreachable because expect_one_of_keywords used above unexpected_keyword => Err(ParserError::ParserError( - format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR}}, got {unexpected_keyword:?}"), + format!("Internal parser error: expected any of {{VIEW, TYPE, COLLATION, TABLE, INDEX, FUNCTION, AGGREGATE, ROLE, POLICY, CONNECTOR, ICEBERG, SCHEMA, USER, OPERATOR, DOMAIN, TRIGGER, EXTENSION, PROCEDURE}}, got {unexpected_keyword:?}"), )), } } @@ -11244,7 +11261,9 @@ impl<'a> Parser<'a> { kind: AlterFunctionKind, ) -> Result { let (function, aggregate_star, aggregate_order_by) = match kind { - AlterFunctionKind::Function => (self.parse_function_desc()?, false, None), + AlterFunctionKind::Function | AlterFunctionKind::Procedure => { + (self.parse_function_desc()?, false, None) + } AlterFunctionKind::Aggregate => self.parse_alter_aggregate_signature()?, }; @@ -11257,7 +11276,9 @@ impl<'a> Parser<'a> { AlterFunctionOperation::SetSchema { schema_name: self.parse_object_name(false)?, } - } else if matches!(kind, AlterFunctionKind::Function) && self.parse_keyword(Keyword::NO) { + } else if matches!(kind, AlterFunctionKind::Function | AlterFunctionKind::Procedure) + && self.parse_keyword(Keyword::NO) + { if !self.parse_keyword(Keyword::DEPENDS) { return self.expected_ref("DEPENDS after NO", self.peek_token_ref()); } @@ -11266,7 +11287,7 @@ impl<'a> Parser<'a> { no: true, extension_name: self.parse_object_name(false)?, } - } else if matches!(kind, AlterFunctionKind::Function) + } else if matches!(kind, AlterFunctionKind::Function | AlterFunctionKind::Procedure) && self.parse_keyword(Keyword::DEPENDS) { self.expect_keywords(&[Keyword::ON, Keyword::EXTENSION])?; @@ -11274,7 +11295,7 @@ impl<'a> Parser<'a> { no: false, extension_name: self.parse_object_name(false)?, } - } else if matches!(kind, AlterFunctionKind::Function) { + } else if matches!(kind, AlterFunctionKind::Function | AlterFunctionKind::Procedure) { let (actions, restrict) = self.parse_alter_function_actions()?; AlterFunctionOperation::Actions { actions, restrict } } else { @@ -11293,6 +11314,113 @@ impl<'a> Parser<'a> { })) } + /// Parse an `ALTER DOMAIN` statement. + pub fn parse_alter_domain(&mut self) -> Result { + let name = self.parse_object_name(false)?; + + let operation = if self.parse_keyword(Keyword::ADD) { + if let Some(constraint) = self.parse_optional_table_constraint()? { + let not_valid = self.parse_keywords(&[Keyword::NOT, Keyword::VALID]); + AlterDomainOperation::AddConstraint { + constraint, + not_valid, + } + } else { + return self.expected_ref("constraint after ADD", self.peek_token_ref()); + } + } else if self.parse_keywords(&[Keyword::DROP, Keyword::CONSTRAINT]) { + let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); + let name = self.parse_identifier()?; + let drop_behavior = self.parse_optional_drop_behavior(); + AlterDomainOperation::DropConstraint { + if_exists, + name, + drop_behavior, + } + } else if self.parse_keywords(&[Keyword::DROP, Keyword::DEFAULT]) { + AlterDomainOperation::DropDefault + } else if self.parse_keywords(&[Keyword::RENAME, Keyword::CONSTRAINT]) { + let old_name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::TO)?; + let new_name = self.parse_identifier()?; + AlterDomainOperation::RenameConstraint { old_name, new_name } + } else if self.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + let new_name = self.parse_identifier()?; + AlterDomainOperation::RenameTo { new_name } + } else if self.parse_keywords(&[Keyword::OWNER, Keyword::TO]) { + AlterDomainOperation::OwnerTo(self.parse_owner()?) + } else if self.parse_keywords(&[Keyword::SET, Keyword::SCHEMA]) { + AlterDomainOperation::SetSchema { + schema_name: self.parse_object_name(false)?, + } + } else if self.parse_keywords(&[Keyword::SET, Keyword::DEFAULT]) { + AlterDomainOperation::SetDefault { + default: self.parse_expr()?, + } + } else if self.parse_keywords(&[Keyword::VALIDATE, Keyword::CONSTRAINT]) { + let name = self.parse_identifier()?; + AlterDomainOperation::ValidateConstraint { name } + } else { + return self.expected_ref( + "ADD, DROP, RENAME, OWNER TO, SET, VALIDATE after ALTER DOMAIN", + self.peek_token_ref(), + ); + }; + + Ok(AlterDomain { name, operation }.into()) + } + + /// Parse an `ALTER TRIGGER` statement. + pub fn parse_alter_trigger(&mut self) -> Result { + let name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::ON)?; + let table_name = self.parse_object_name(false)?; + + let operation = if self.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + let new_name = self.parse_identifier()?; + AlterTriggerOperation::RenameTo { new_name } + } else { + return self.expected_ref("RENAME TO after ALTER TRIGGER ... ON ...", self.peek_token_ref()); + }; + + Ok(AlterTrigger { + name, + table_name, + operation, + } + .into()) + } + + /// Parse an `ALTER EXTENSION` statement. + pub fn parse_alter_extension(&mut self) -> Result { + let name = self.parse_identifier()?; + + let operation = if self.parse_keyword(Keyword::UPDATE) { + let version = if self.parse_keyword(Keyword::TO) { + Some(self.parse_identifier()?) + } else { + None + }; + AlterExtensionOperation::UpdateTo { version } + } else if self.parse_keywords(&[Keyword::SET, Keyword::SCHEMA]) { + AlterExtensionOperation::SetSchema { + schema_name: self.parse_object_name(false)?, + } + } else if self.parse_keywords(&[Keyword::OWNER, Keyword::TO]) { + AlterExtensionOperation::OwnerTo(self.parse_owner()?) + } else if self.parse_keywords(&[Keyword::RENAME, Keyword::TO]) { + let new_name = self.parse_identifier()?; + AlterExtensionOperation::RenameTo { new_name } + } else { + return self.expected_ref( + "UPDATE, SET SCHEMA, OWNER TO, or RENAME TO after ALTER EXTENSION", + self.peek_token_ref(), + ); + }; + + Ok(AlterExtension { name, operation }.into()) + } + /// Parse a [Statement::AlterTable] pub fn parse_alter_table(&mut self, iceberg: bool) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 89f164033f..3e1c729692 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9615,3 +9615,149 @@ fn parse_create_aggregate_with_moving_aggregate_options() { _ => panic!("Expected CreateAggregate, got: {stmt:?}"), } } + +#[test] +fn alter_table_set_tablespace() { + let sql = "ALTER TABLE t SET TABLESPACE ts"; + let Statement::AlterTable(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.to_string(), "t"); + assert_eq!(stmt.operations.len(), 1); + assert_eq!( + stmt.operations[0], + AlterTableOperation::SetTablespace { + tablespace_name: "ts".into() + } + ); +} + +#[test] +fn alter_index_set_tablespace() { + let sql = "ALTER INDEX idx SET TABLESPACE ts"; + let Statement::AlterIndex { name, operation } = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(name.to_string(), "idx"); + assert_eq!( + operation, + AlterIndexOperation::SetTablespace { + tablespace_name: "ts".into() + } + ); +} + +#[test] +fn alter_domain_add_constraint() { + let sql = "ALTER DOMAIN positive_int ADD CONSTRAINT positive CHECK (VALUE > 0)"; + let Statement::AlterDomain(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.to_string(), "positive_int"); + assert!(matches!( + stmt.operation, + AlterDomainOperation::AddConstraint { not_valid: false, .. } + )); +} + +#[test] +fn alter_domain_drop_constraint() { + let sql = "ALTER DOMAIN email DROP CONSTRAINT valid_email"; + let Statement::AlterDomain(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.to_string(), "email"); + assert!(matches!( + stmt.operation, + AlterDomainOperation::DropConstraint { + if_exists: false, + .. + } + )); +} + +#[test] +fn alter_domain_drop_constraint_if_exists() { + let sql = "ALTER DOMAIN email DROP CONSTRAINT IF EXISTS valid_email"; + let Statement::AlterDomain(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(matches!( + stmt.operation, + AlterDomainOperation::DropConstraint { + if_exists: true, + .. + } + )); +} + +#[test] +fn alter_trigger_rename() { + let sql = "ALTER TRIGGER old_trigger ON orders RENAME TO new_trigger"; + let Statement::AlterTrigger(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "old_trigger"); + assert_eq!(stmt.table_name.to_string(), "orders"); + assert_eq!( + stmt.operation, + AlterTriggerOperation::RenameTo { + new_name: "new_trigger".into() + } + ); +} + +#[test] +fn alter_extension_update() { + let sql = "ALTER EXTENSION pgcrypto UPDATE"; + let Statement::AlterExtension(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "pgcrypto"); + assert_eq!( + stmt.operation, + AlterExtensionOperation::UpdateTo { version: None } + ); +} + +#[test] +fn alter_extension_update_to_version() { + let sql = "ALTER EXTENSION pgcrypto UPDATE TO '3.4'"; + let Statement::AlterExtension(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "pgcrypto"); + assert!(matches!( + stmt.operation, + AlterExtensionOperation::UpdateTo { version: Some(_) } + )); +} + +#[test] +fn alter_procedure_set_search_path() { + let sql = "ALTER PROCEDURE myproc(integer) SET search_path = public"; + let Statement::AlterFunction(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.kind, AlterFunctionKind::Procedure); + assert_eq!(stmt.function.name.to_string(), "myproc"); + assert!(matches!( + stmt.operation, + AlterFunctionOperation::Actions { .. } + )); +} + +#[test] +fn alter_procedure_rename() { + let sql = "ALTER PROCEDURE myproc(integer, text) RENAME TO renamed_proc"; + let Statement::AlterFunction(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.kind, AlterFunctionKind::Procedure); + assert_eq!( + stmt.operation, + AlterFunctionOperation::RenameTo { + new_name: "renamed_proc".into() + } + ); +} From 4bffb07ed9c33f9aa58b10645d9db2e4534f6650 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:21:05 +0900 Subject: [PATCH 27/30] chore: bump version to 0.60.9 --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c6ffb078d..609e4b98d0 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)" -version = "0.60.8" +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)" +version = "0.60.9" authors = ["Filipe Guerreiro "] homepage = "https://github.com/fmguerreiro/datafusion-sqlparser-rs" documentation = "https://docs.rs/pgmold-sqlparser/" From ceb1cf372dc5523386469dd673cab17a76328535 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:40:50 +0900 Subject: [PATCH 28/30] feat(ast): add CreateStatistics, CreateAccessMethod, CreateEventTrigger, CreateTransform AST nodes Add structs and enums for four PostgreSQL-specific DDL statements: - CreateStatistics with StatisticsKind (NDistinct, Dependencies, Mcv) - CreateAccessMethod with AccessMethodType (Index, Table) - CreateEventTrigger with EventTriggerEvent (DdlCommandStart, DdlCommandEnd, TableRewrite, SqlDrop) - CreateTransform / TransformElement with OR REPLACE support Adds TRANSFORM keyword to keywords.rs. Closes pgmold-103, pgmold-104, pgmold-105, pgmold-106. --- src/ast/ddl.rs | 266 +++++++++++++++++++++++++++++++++++++++++++++++ src/ast/mod.rs | 30 ++++++ src/ast/spans.rs | 4 + src/keywords.rs | 1 + 4 files changed, 301 insertions(+) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 0abe552efd..a34c5d79ed 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -6512,3 +6512,269 @@ impl From for crate::ast::Statement { crate::ast::Statement::CreateSubscription(v) } } + +/// A kind of extended statistics collected by `CREATE STATISTICS`. +/// +/// Note: this is a PostgreSQL-specific concept. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum StatisticsKind { + /// `ndistinct` — n-distinct statistics + NDistinct, + /// `dependencies` — functional dependency statistics + Dependencies, + /// `mcv` — most-common-values statistics + Mcv, +} + +impl fmt::Display for StatisticsKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + StatisticsKind::NDistinct => write!(f, "ndistinct"), + StatisticsKind::Dependencies => write!(f, "dependencies"), + StatisticsKind::Mcv => write!(f, "mcv"), + } + } +} + +/// A `CREATE STATISTICS` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateStatistics { + /// Optional `IF NOT EXISTS` clause. + pub if_not_exists: bool, + /// The statistics object name, e.g. `public.s`. + pub name: ObjectName, + /// Optional `(ndistinct, dependencies, mcv)` kind list. + pub kinds: Vec, + /// The expressions (columns or arbitrary expressions) to collect statistics on. + pub on: Vec, + /// The table to collect statistics from. + pub from: ObjectName, +} + +impl fmt::Display for CreateStatistics { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE STATISTICS")?; + if self.if_not_exists { + write!(f, " IF NOT EXISTS")?; + } + write!(f, " {}", self.name)?; + if !self.kinds.is_empty() { + write!(f, " ({})", display_comma_separated(&self.kinds))?; + } + write!(f, " ON {}", display_comma_separated(&self.on))?; + write!(f, " FROM {}", self.from)?; + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateStatistics) -> Self { + crate::ast::Statement::CreateStatistics(v) + } +} + +/// The type of access method in `CREATE ACCESS METHOD`. +/// +/// Note: this is a PostgreSQL-specific concept. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum AccessMethodType { + /// `INDEX` — an index access method + Index, + /// `TABLE` — a table access method + Table, +} + +impl fmt::Display for AccessMethodType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessMethodType::Index => write!(f, "INDEX"), + AccessMethodType::Table => write!(f, "TABLE"), + } + } +} + +/// A `CREATE ACCESS METHOD` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateAccessMethod { + /// The access method name. + pub name: Ident, + /// `TYPE INDEX | TABLE` + pub method_type: AccessMethodType, + /// `HANDLER handler_function` + pub handler: ObjectName, +} + +impl fmt::Display for CreateAccessMethod { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "CREATE ACCESS METHOD {name} TYPE {method_type} HANDLER {handler}", + name = self.name, + method_type = self.method_type, + handler = self.handler, + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateAccessMethod) -> Self { + crate::ast::Statement::CreateAccessMethod(v) + } +} + +/// An event name for `CREATE EVENT TRIGGER`. +/// +/// Note: this is a PostgreSQL-specific concept. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum EventTriggerEvent { + /// `ddl_command_start` + DdlCommandStart, + /// `ddl_command_end` + DdlCommandEnd, + /// `table_rewrite` + TableRewrite, + /// `sql_drop` + SqlDrop, +} + +impl fmt::Display for EventTriggerEvent { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + EventTriggerEvent::DdlCommandStart => write!(f, "ddl_command_start"), + EventTriggerEvent::DdlCommandEnd => write!(f, "ddl_command_end"), + EventTriggerEvent::TableRewrite => write!(f, "table_rewrite"), + EventTriggerEvent::SqlDrop => write!(f, "sql_drop"), + } + } +} + +/// A `CREATE EVENT TRIGGER` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateEventTrigger { + /// The trigger name. + pub name: Ident, + /// The event that fires the trigger. + pub event: EventTriggerEvent, + /// Optional `WHEN TAG IN ('tag', ...)` filter. + pub when_tags: Option>, + /// The handler function name (from `EXECUTE FUNCTION name()`). + pub execute: ObjectName, + /// Whether `PROCEDURE` was used instead of `FUNCTION` (older alias). + pub is_procedure: bool, +} + +impl fmt::Display for CreateEventTrigger { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE EVENT TRIGGER {} ON {}", self.name, self.event)?; + if let Some(tags) = &self.when_tags { + write!(f, " WHEN TAG IN ({})", display_comma_separated(tags))?; + } + let func_kw = if self.is_procedure { + "PROCEDURE" + } else { + "FUNCTION" + }; + write!(f, " EXECUTE {func_kw} {}()", self.execute)?; + Ok(()) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateEventTrigger) -> Self { + crate::ast::Statement::CreateEventTrigger(v) + } +} + +/// A single element in a `CREATE TRANSFORM` transform list. +/// +/// Either `FROM SQL WITH FUNCTION name(arg_types)` or `TO SQL WITH FUNCTION name(arg_types)`. +/// +/// Note: this is a PostgreSQL-specific concept. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct TransformElement { + /// `true` = FROM SQL, `false` = TO SQL + pub is_from: bool, + /// The function name. + pub function: ObjectName, + /// The argument type list (may be empty). + pub arg_types: Vec, +} + +impl fmt::Display for TransformElement { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let direction = if self.is_from { "FROM" } else { "TO" }; + write!( + f, + "{direction} SQL WITH FUNCTION {}({})", + self.function, + display_comma_separated(&self.arg_types), + ) + } +} + +/// A `CREATE TRANSFORM` statement. +/// +/// Note: this is a PostgreSQL-specific statement. +/// +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateTransform { + /// Whether `OR REPLACE` was specified. + pub or_replace: bool, + /// The data type being transformed. + pub type_name: DataType, + /// The procedural language name. + pub language: Ident, + /// The list of transform elements (FROM SQL and/or TO SQL). + pub elements: Vec, +} + +impl fmt::Display for CreateTransform { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE")?; + if self.or_replace { + write!(f, " OR REPLACE")?; + } + write!( + f, + " TRANSFORM FOR {} LANGUAGE {} ({})", + self.type_name, + self.language, + display_comma_separated(&self.elements), + ) + } +} + +impl From for crate::ast::Statement { + fn from(v: CreateTransform) -> Self { + crate::ast::Statement::CreateTransform(v) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 6766f4f2f4..7a70d17999 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -77,6 +77,8 @@ pub use self::ddl::{ CreatePolicyCommand, CreatePolicyType, CreatePublication, CreateSubscription, CreateTable, CreateTextSearchConfiguration, CreateTextSearchDictionary, CreateTextSearchParser, CreateTextSearchTemplate, CreateTrigger, PublicationTarget, + AccessMethodType, CreateAccessMethod, CreateEventTrigger, CreateStatistics, CreateTransform, + EventTriggerEvent, StatisticsKind, TransformElement, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, FdwRoutineClause, ForValues, FunctionReturnType, GeneratedAs, @@ -4049,6 +4051,30 @@ pub enum Statement { /// CreateSubscription(CreateSubscription), /// ```sql + /// CREATE STATISTICS [ IF NOT EXISTS ] name [ ( kind [, ...] ) ] ON expr [, ...] FROM table_name + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateStatistics(CreateStatistics), + /// ```sql + /// CREATE ACCESS METHOD name TYPE INDEX | TABLE HANDLER handler_function + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateAccessMethod(CreateAccessMethod), + /// ```sql + /// CREATE EVENT TRIGGER name ON event [ WHEN TAG IN ( 'tag' [, ...] ) ] EXECUTE FUNCTION | PROCEDURE function_name() + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateEventTrigger(CreateEventTrigger), + /// ```sql + /// CREATE [ OR REPLACE ] TRANSFORM FOR type_name LANGUAGE lang_name ( transform_element_list ) + /// ``` + /// Note: this is a PostgreSQL-specific statement. + /// + CreateTransform(CreateTransform), + /// ```sql /// DROP EXTENSION [ IF EXISTS ] name [, ...] [ CASCADE | RESTRICT ] /// ``` /// Note: this is a PostgreSQL-specific statement. @@ -5537,6 +5563,10 @@ impl fmt::Display for Statement { Statement::CreateTextSearchTemplate(v) => write!(f, "{v}"), Statement::CreatePublication(v) => write!(f, "{v}"), Statement::CreateSubscription(v) => write!(f, "{v}"), + Statement::CreateStatistics(v) => write!(f, "{v}"), + Statement::CreateAccessMethod(v) => write!(f, "{v}"), + Statement::CreateEventTrigger(v) => write!(f, "{v}"), + Statement::CreateTransform(v) => write!(f, "{v}"), Statement::DropExtension(drop_extension) => write!(f, "{drop_extension}"), Statement::DropOperator(drop_operator) => write!(f, "{drop_operator}"), Statement::DropOperatorFamily(drop_operator_family) => { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 690d4ea79b..15fe2c18c8 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -390,6 +390,10 @@ impl Spanned for Statement { Statement::CreateTextSearchTemplate(_) => Span::empty(), Statement::CreatePublication(_) => Span::empty(), Statement::CreateSubscription(_) => Span::empty(), + Statement::CreateStatistics(_) => Span::empty(), + Statement::CreateAccessMethod(_) => Span::empty(), + Statement::CreateEventTrigger(_) => Span::empty(), + Statement::CreateTransform(_) => Span::empty(), Statement::DropExtension(drop_extension) => drop_extension.span(), Statement::DropOperator(drop_operator) => drop_operator.span(), Statement::DropOperatorFamily(drop_operator_family) => drop_operator_family.span(), diff --git a/src/keywords.rs b/src/keywords.rs index d86e045695..516dc712de 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1074,6 +1074,7 @@ define_keywords!( TRAN, TRANSACTION, TRANSIENT, + TRANSFORM, TRANSLATE, TRANSLATE_REGEX, TRANSLATION, From 8c97e3db998df72dc748c42a9b439928164445f5 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:40:56 +0900 Subject: [PATCH 29/30] feat(parser): parse CREATE STATISTICS, ACCESS METHOD, EVENT TRIGGER, TRANSFORM Add parse_create_statistics, parse_create_access_method, parse_create_event_trigger, and parse_create_transform to the parser. - STATISTICS: name, optional IF NOT EXISTS, optional (kind, ...) list, ON expr-list, FROM table - ACCESS METHOD: name TYPE INDEX|TABLE HANDLER function - EVENT TRIGGER: name ON event [WHEN TAG IN (...)] EXECUTE FUNCTION|PROCEDURE name() - TRANSFORM: [OR REPLACE] FOR type LANGUAGE lang (element, ...) TRANSFORM is dispatched before the or_replace error guard. --- src/parser/mod.rs | 164 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9ef8cfb5c4..5308379d5d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5175,6 +5175,8 @@ impl<'a> Parser<'a> { self.parse_create_user(or_replace).map(Into::into) } else if self.parse_keyword(Keyword::AGGREGATE) { self.parse_create_aggregate(or_replace).map(Into::into) + } else if self.parse_keyword(Keyword::TRANSFORM) { + self.parse_create_transform(or_replace).map(Into::into) } else if or_replace { self.expected_ref( "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION after CREATE OR REPLACE", @@ -5232,6 +5234,12 @@ impl<'a> Parser<'a> { self.parse_create_publication().map(Into::into) } else if self.parse_keyword(Keyword::SUBSCRIPTION) { self.parse_create_subscription().map(Into::into) + } else if self.parse_keyword(Keyword::STATISTICS) { + self.parse_create_statistics().map(Into::into) + } else if self.parse_keywords(&[Keyword::ACCESS, Keyword::METHOD]) { + self.parse_create_access_method().map(Into::into) + } else if self.parse_keywords(&[Keyword::EVENT, Keyword::TRIGGER]) { + self.parse_create_event_trigger().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -20268,6 +20276,162 @@ impl<'a> Parser<'a> { }) } + /// Parse a `CREATE STATISTICS` statement. + /// + /// See + pub fn parse_create_statistics(&mut self) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_object_name(false)?; + + let kinds = if self.consume_token(&Token::LParen) { + let kinds = self.parse_comma_separated(|p| { + let ident = p.parse_identifier()?; + match ident.value.to_lowercase().as_str() { + "ndistinct" => Ok(StatisticsKind::NDistinct), + "dependencies" => Ok(StatisticsKind::Dependencies), + "mcv" => Ok(StatisticsKind::Mcv), + other => Err(ParserError::ParserError(format!( + "Unknown statistics kind: {other}" + ))), + } + })?; + self.expect_token(&Token::RParen)?; + kinds + } else { + vec![] + }; + + self.expect_keyword_is(Keyword::ON)?; + let on = self.parse_comma_separated(Parser::parse_expr)?; + self.expect_keyword_is(Keyword::FROM)?; + let from = self.parse_object_name(false)?; + + Ok(CreateStatistics { + if_not_exists, + name, + kinds, + on, + from, + }) + } + + /// Parse a `CREATE ACCESS METHOD` statement. + /// + /// See + pub fn parse_create_access_method(&mut self) -> Result { + let name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::TYPE)?; + let method_type = if self.parse_keyword(Keyword::INDEX) { + AccessMethodType::Index + } else if self.parse_keyword(Keyword::TABLE) { + AccessMethodType::Table + } else { + return self.expected_ref("INDEX or TABLE after TYPE", self.peek_token_ref()); + }; + self.expect_keyword_is(Keyword::HANDLER)?; + let handler = self.parse_object_name(false)?; + + Ok(CreateAccessMethod { + name, + method_type, + handler, + }) + } + + /// Parse a `CREATE EVENT TRIGGER` statement. + /// + /// See + pub fn parse_create_event_trigger(&mut self) -> Result { + let name = self.parse_identifier()?; + self.expect_keyword_is(Keyword::ON)?; + let event_ident = self.parse_identifier()?; + let event = match event_ident.value.to_lowercase().as_str() { + "ddl_command_start" => EventTriggerEvent::DdlCommandStart, + "ddl_command_end" => EventTriggerEvent::DdlCommandEnd, + "table_rewrite" => EventTriggerEvent::TableRewrite, + "sql_drop" => EventTriggerEvent::SqlDrop, + other => { + return Err(ParserError::ParserError(format!( + "Unknown event trigger event: {other}" + ))) + } + }; + + let when_tags = if self.parse_keyword(Keyword::WHEN) { + self.expect_keyword_is(Keyword::TAG)?; + self.expect_keyword_is(Keyword::IN)?; + self.expect_token(&Token::LParen)?; + let tags = self.parse_comma_separated(|p| p.parse_value().map(|v| v.value))?; + self.expect_token(&Token::RParen)?; + Some(tags) + } else { + None + }; + + self.expect_keyword_is(Keyword::EXECUTE)?; + let is_procedure = if self.parse_keyword(Keyword::FUNCTION) { + false + } else if self.parse_keyword(Keyword::PROCEDURE) { + true + } else { + return self.expected_ref("FUNCTION or PROCEDURE after EXECUTE", self.peek_token_ref()); + }; + let execute = self.parse_object_name(false)?; + self.expect_token(&Token::LParen)?; + self.expect_token(&Token::RParen)?; + + Ok(CreateEventTrigger { + name, + event, + when_tags, + execute, + is_procedure, + }) + } + + /// Parse a `CREATE [OR REPLACE] TRANSFORM` statement. + /// + /// See + pub fn parse_create_transform(&mut self, or_replace: bool) -> Result { + self.expect_keyword_is(Keyword::FOR)?; + let type_name = self.parse_data_type()?; + self.expect_keyword_is(Keyword::LANGUAGE)?; + let language = self.parse_identifier()?; + self.expect_token(&Token::LParen)?; + let elements = self.parse_comma_separated(|p| { + let is_from = if p.parse_keyword(Keyword::FROM) { + true + } else { + p.expect_keyword_is(Keyword::TO)?; + false + }; + p.expect_keyword_is(Keyword::SQL)?; + p.expect_keyword_is(Keyword::WITH)?; + p.expect_keyword_is(Keyword::FUNCTION)?; + let function = p.parse_object_name(false)?; + p.expect_token(&Token::LParen)?; + let arg_types = if p.peek_token().token == Token::RParen { + vec![] + } else { + p.parse_comma_separated(|p| p.parse_data_type())? + }; + p.expect_token(&Token::RParen)?; + Ok(TransformElement { + is_from, + function, + arg_types, + }) + })?; + self.expect_token(&Token::RParen)?; + + Ok(CreateTransform { + or_replace, + type_name, + language, + elements, + }) + } + /// The index of the first unprocessed token. pub fn index(&self) -> usize { self.index From 2be02026ec8a4d957a0d8a462eaea2345cd94ed1 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Sat, 18 Apr 2026 12:41:00 +0900 Subject: [PATCH 30/30] test: add round-trip tests for CREATE STATISTICS, ACCESS METHOD, EVENT TRIGGER, TRANSFORM --- tests/sqlparser_postgres.rs | 109 ++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 5e9b422873..1d90066ba6 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9867,3 +9867,112 @@ fn parse_create_subscription_with_options() { other => panic!("unexpected option: {other:?}"), } } + +#[test] +fn parse_create_statistics_basic() { + let sql = "CREATE STATISTICS public.s ON a, b FROM public.t"; + let Statement::CreateStatistics(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(!stmt.if_not_exists); + assert_eq!(stmt.name.to_string(), "public.s"); + assert!(stmt.kinds.is_empty()); + assert_eq!(stmt.on.len(), 2); + assert_eq!(stmt.from.to_string(), "public.t"); +} + +#[test] +fn parse_create_statistics_if_not_exists_with_kinds() { + let sql = "CREATE STATISTICS IF NOT EXISTS mystat (ndistinct, dependencies, mcv) ON col1, col2 FROM mytable"; + let Statement::CreateStatistics(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(stmt.if_not_exists); + assert_eq!(stmt.name.to_string(), "mystat"); + assert_eq!( + stmt.kinds, + vec![ + StatisticsKind::NDistinct, + StatisticsKind::Dependencies, + StatisticsKind::Mcv, + ] + ); + assert_eq!(stmt.on.len(), 2); +} + +#[test] +fn parse_create_access_method_index() { + let sql = "CREATE ACCESS METHOD my_am TYPE INDEX HANDLER bthandler"; + let Statement::CreateAccessMethod(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "my_am"); + assert_eq!(stmt.method_type, AccessMethodType::Index); + assert_eq!(stmt.handler.to_string(), "bthandler"); +} + +#[test] +fn parse_create_access_method_table() { + let sql = "CREATE ACCESS METHOD my_tam TYPE TABLE HANDLER heap_tableam_handler"; + let Statement::CreateAccessMethod(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "my_tam"); + assert_eq!(stmt.method_type, AccessMethodType::Table); + assert_eq!(stmt.handler.to_string(), "heap_tableam_handler"); +} + +#[test] +fn parse_create_event_trigger_basic() { + let sql = "CREATE EVENT TRIGGER myet ON ddl_command_start EXECUTE FUNCTION public.handler()"; + let Statement::CreateEventTrigger(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.name.value, "myet"); + assert_eq!(stmt.event, EventTriggerEvent::DdlCommandStart); + assert!(stmt.when_tags.is_none()); + assert_eq!(stmt.execute.to_string(), "public.handler"); + assert!(!stmt.is_procedure); +} + +#[test] +fn parse_create_event_trigger_with_when_tags() { + let sql = "CREATE EVENT TRIGGER myet ON ddl_command_end WHEN TAG IN ('CREATE TABLE', 'ALTER TABLE') EXECUTE FUNCTION abort_any_command()"; + let Statement::CreateEventTrigger(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.event, EventTriggerEvent::DdlCommandEnd); + let tags = stmt.when_tags.unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].to_string(), "'CREATE TABLE'"); + assert_eq!(tags[1].to_string(), "'ALTER TABLE'"); +} + +#[test] +fn parse_create_transform_basic() { + let sql = "CREATE TRANSFORM FOR INT LANGUAGE sql (FROM SQL WITH FUNCTION f1(internal), TO SQL WITH FUNCTION f2(INT))"; + let Statement::CreateTransform(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(!stmt.or_replace); + assert_eq!(stmt.type_name.to_string(), "INT"); + assert_eq!(stmt.language.value, "sql"); + assert_eq!(stmt.elements.len(), 2); + assert!(stmt.elements[0].is_from); + assert_eq!(stmt.elements[0].function.to_string(), "f1"); + assert!(!stmt.elements[1].is_from); + assert_eq!(stmt.elements[1].function.to_string(), "f2"); +} + +#[test] +fn parse_create_or_replace_transform() { + let sql = "CREATE OR REPLACE TRANSFORM FOR BIGINT LANGUAGE plpgsql (FROM SQL WITH FUNCTION int8recv(internal))"; + let Statement::CreateTransform(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert!(stmt.or_replace); + assert_eq!(stmt.type_name.to_string(), "BIGINT"); + assert_eq!(stmt.language.value, "plpgsql"); + assert_eq!(stmt.elements.len(), 1); + assert!(stmt.elements[0].is_from); +}