From 8369e81ea4bfb9202ac5a1ea8c7dcab91375f685 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Mon, 8 Dec 2025 15:02:57 +0900 Subject: [PATCH 1/8] Add PostgreSQL PARTITION OF syntax support (#2042). --- src/ast/ddl.rs | 90 ++++++++++++- src/ast/helpers/stmt_create_table.rs | 24 +++- src/ast/mod.rs | 13 +- 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, 390 insertions(+), 9 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 4e042a365..325cbab90 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -2697,6 +2697,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. @@ -2792,6 +2800,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}")?; } @@ -2806,12 +2817,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) @@ -3053,6 +3071,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 fe950c909..62dbbbcba 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 467678602..2c170e7f3 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -69,12 +69,13 @@ 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, OperatorFamilyDropItem, OperatorFamilyItem, - OperatorOption, OperatorPurpose, Owner, Partition, ProcedureParam, ReferentialAction, - RenameTableNameKind, ReplicaIdentity, TagsColumnOption, TriggerObjectKind, Truncate, + DropOperatorSignature, DropTrigger, ForValues, GeneratedAs, GeneratedExpressionMode, + IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, IdentityPropertyKind, + IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, KeyOrIndexDisplay, Msck, + NullsDistinctOption, OperatorArgTypes, OperatorClassItem, OperatorFamilyDropItem, + OperatorFamilyItem, OperatorOption, OperatorPurpose, Owner, Partition, PartitionBoundValue, + ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, TagsColumnOption, + TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d4e843157..d120a3f04 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -554,6 +554,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 f06842ec6..87c77379c 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 d1c4fe05b..6ebf5c62c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7887,6 +7887,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()?; @@ -7911,6 +7920,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]); @@ -7988,6 +8004,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) @@ -8047,6 +8065,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 73a1afe26..4a2f29e15 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 70e0aab49..0eee96e69 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 9f4564ef2..717112d1b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -6130,6 +6130,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, @@ -7914,3 +7916,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 f126c9395c7334362b8d459c235e54fe0b048251 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Tue, 16 Dec 2025 10:31:05 +0900 Subject: [PATCH 2/8] Remove PostgreSQL dialect check for PARTITION OF. --- src/parser/mod.rs | 4 +--- tests/sqlparser_postgres.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6ebf5c62c..216f544eb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7888,9 +7888,7 @@ impl<'a> Parser<'a> { 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]) - { + let partition_of = if self.parse_keywords(&[Keyword::PARTITION, Keyword::OF]) { Some(self.parse_object_name(allow_unquoted_hyphen)?) } else { None diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 717112d1b..5fbd35b27 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -8104,3 +8104,34 @@ CONSTRAINT check_date CHECK (order_date >= '2023-01-01')\ _ => panic!("Expected CreateTable"), } } + +#[test] +fn parse_create_table_partition_of_works_without_dialect_check() { + use sqlparser::dialect::{GenericDialect, MySqlDialect, SQLiteDialect}; + use sqlparser::test_utils::TestedDialects; + + let sql = "CREATE TABLE measurement_y2006m02 PARTITION OF measurement FOR VALUES FROM ('2006-02-01') TO ('2006-03-01')"; + let dialects = TestedDialects::new(vec![ + Box::new(GenericDialect {}), + Box::new(PostgreSqlDialect {}), + Box::new(MySqlDialect {}), + Box::new(SQLiteDialect {}), + ]); + match dialects.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()); + } + _ => panic!("Expected ForValues::From"), + } + } + _ => panic!("Expected CreateTable"), + } +} From 4fc212fb918ede2a086731ec66179556ef73d48e Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Tue, 16 Dec 2025 10:34:44 +0900 Subject: [PATCH 3/8] Fix doc comments for PARTITION OF support. --- src/ast/ddl.rs | 2 +- src/parser/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 325cbab90..a2b79e554 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -3071,7 +3071,7 @@ impl fmt::Display for CreateTable { } } -/// PostgreSQL partition bound specification for PARTITION OF. +/// PostgreSQL partition bound specification for `PARTITION OF`. /// /// Specifies partition bounds for a child partition table. /// diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 216f544eb..83b8d3164 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -8063,7 +8063,7 @@ impl<'a> Parser<'a> { } } - /// Parse PostgreSQL partition bound specification for PARTITION OF. + /// Parse [ForValues] of a `PARTITION OF` clause. /// /// Parses: `FOR VALUES partition_bound_spec | DEFAULT` /// @@ -8106,7 +8106,7 @@ impl<'a> Parser<'a> { } } - /// Parse a single partition bound value (MINVALUE, MAXVALUE, or expression). + /// Parse a single [PartitionBoundValue]. fn parse_partition_bound_value(&mut self) -> Result { if self.parse_keyword(Keyword::MINVALUE) { Ok(PartitionBoundValue::MinValue) From 7f7e6ba665b131c40ba3cce38502d50f91c7c3e2 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 19 Dec 2025 12:02:24 +0900 Subject: [PATCH 4/8] Remove redundant PARTITION OF dialect test. --- tests/sqlparser_postgres.rs | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 5fbd35b27..717112d1b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -8104,34 +8104,3 @@ CONSTRAINT check_date CHECK (order_date >= '2023-01-01')\ _ => panic!("Expected CreateTable"), } } - -#[test] -fn parse_create_table_partition_of_works_without_dialect_check() { - use sqlparser::dialect::{GenericDialect, MySqlDialect, SQLiteDialect}; - use sqlparser::test_utils::TestedDialects; - - let sql = "CREATE TABLE measurement_y2006m02 PARTITION OF measurement FOR VALUES FROM ('2006-02-01') TO ('2006-03-01')"; - let dialects = TestedDialects::new(vec![ - Box::new(GenericDialect {}), - Box::new(PostgreSqlDialect {}), - Box::new(MySqlDialect {}), - Box::new(SQLiteDialect {}), - ]); - match dialects.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()); - } - _ => panic!("Expected ForValues::From"), - } - } - _ => panic!("Expected CreateTable"), - } -} From f6611715e014f5b23c0da4926b44ba4c4f88e0f5 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Fri, 19 Dec 2025 12:42:16 +0900 Subject: [PATCH 5/8] Improve PARTITION OF error handling and add tests. - Add explicit check for FOR/DEFAULT after PARTITION OF with clear error message - Document intentional removal of dialect check for multi-dialect tool support - Add negative test cases for malformed PARTITION OF syntax --- src/parser/mod.rs | 12 +++++++++++- tests/sqlparser_postgres.rs | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 83b8d3164..d85f96af0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7888,6 +7888,9 @@ impl<'a> Parser<'a> { let table_name = self.parse_object_name(allow_unquoted_hyphen)?; // PostgreSQL PARTITION OF for child partition tables + // Note: This is a PostgreSQL-specific feature, but the dialect check was intentionally + // removed to allow GenericDialect and other dialects to parse this syntax. This enables + // multi-dialect SQL tools to work with PostgreSQL-specific DDL statements. let partition_of = if self.parse_keywords(&[Keyword::PARTITION, Keyword::OF]) { Some(self.parse_object_name(allow_unquoted_hyphen)?) } else { @@ -7920,7 +7923,14 @@ impl<'a> Parser<'a> { // PostgreSQL PARTITION OF: partition bound specification let for_values = if partition_of.is_some() { - Some(self.parse_partition_for_values()?) + if self.peek_keyword(Keyword::FOR) || self.peek_keyword(Keyword::DEFAULT) { + Some(self.parse_partition_for_values()?) + } else { + return self.expected( + "FOR VALUES or DEFAULT after PARTITION OF", + self.peek_token(), + ); + } } else { None }; diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 717112d1b..3669196df 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -8104,3 +8104,42 @@ CONSTRAINT check_date CHECK (order_date >= '2023-01-01')\ _ => panic!("Expected CreateTable"), } } + +#[test] +fn parse_create_table_partition_of_errors() { + let sql = "CREATE TABLE p PARTITION OF parent"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("FOR VALUES or DEFAULT"), + "Expected error about FOR VALUES, got: {err}" + ); + + let sql = "CREATE TABLE p PARTITION OF parent WITH (fillfactor = 70)"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("FOR VALUES or DEFAULT"), + "Expected error about FOR VALUES, got: {err}" + ); + + let sql = "CREATE TABLE p PARTITION OF parent FOR VALUES RANGE (1, 10)"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("IN, FROM, or WITH"), + "Expected error about invalid keyword after FOR VALUES, got: {err}" + ); + + let sql = "CREATE TABLE p PARTITION OF parent FOR VALUES FROM (1)"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("TO"), + "Expected error about missing TO clause, got: {err}" + ); +} From c9b5fde29a7ea0b4d6cd069d2c3d1b1afacea85f Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Tue, 23 Dec 2025 09:45:32 +0900 Subject: [PATCH 6/8] Add validation for empty value lists in PARTITION OF syntax. --- src/parser/mod.rs | 9 +++++++++ tests/sqlparser_postgres.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index d85f96af0..9a88cc479 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -8088,16 +8088,25 @@ impl<'a> Parser<'a> { if self.parse_keyword(Keyword::IN) { // FOR VALUES IN (expr, ...) self.expect_token(&Token::LParen)?; + if self.peek_token() == Token::RParen { + return self.expected("at least one value", self.peek_token()); + } 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)?; + if self.peek_token() == Token::RParen { + return self.expected("at least one value", self.peek_token()); + } 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)?; + if self.peek_token() == Token::RParen { + return self.expected("at least one value", self.peek_token()); + } let to = self.parse_comma_separated(Parser::parse_partition_bound_value)?; self.expect_token(&Token::RParen)?; Ok(ForValues::From { from, to }) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 3669196df..6974d07ae 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -8142,4 +8142,31 @@ fn parse_create_table_partition_of_errors() { err.contains("TO"), "Expected error about missing TO clause, got: {err}" ); + + let sql = "CREATE TABLE p PARTITION OF parent FOR VALUES IN ()"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("at least one value"), + "Expected error about empty value list in IN clause, got: {err}" + ); + + let sql = "CREATE TABLE p PARTITION OF parent FOR VALUES FROM () TO (10)"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("at least one value"), + "Expected error about empty FROM list, got: {err}" + ); + + let sql = "CREATE TABLE p PARTITION OF parent FOR VALUES FROM (1) TO ()"; + let result = pg_and_generic().parse_sql_statements(sql); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("at least one value"), + "Expected error about empty TO list, got: {err}" + ); } From cf55b4c86ebadcddc90f77b673519d839eb5a85a Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Tue, 23 Dec 2025 09:50:12 +0900 Subject: [PATCH 7/8] Document PARTITION OF mutual exclusivity with AS SELECT and LIKE. --- src/parser/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9a88cc479..eb47d9e4a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7891,6 +7891,12 @@ impl<'a> Parser<'a> { // Note: This is a PostgreSQL-specific feature, but the dialect check was intentionally // removed to allow GenericDialect and other dialects to parse this syntax. This enables // multi-dialect SQL tools to work with PostgreSQL-specific DDL statements. + // + // PARTITION OF can be combined with other table definition clauses in the AST, + // though PostgreSQL itself prohibits PARTITION OF with AS SELECT or LIKE clauses. + // The parser accepts these combinations for flexibility; semantic validation + // is left to downstream tools. + // Child partitions can have their own constraints and indexes. let partition_of = if self.parse_keywords(&[Keyword::PARTITION, Keyword::OF]) { Some(self.parse_object_name(allow_unquoted_hyphen)?) } else { From b00722f442d52a5f1879f5c6fe9aec1328612fa6 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Tue, 23 Dec 2025 10:24:06 +0900 Subject: [PATCH 8/8] Add span support for PARTITION OF fields. --- src/ast/spans.rs | 50 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d120a3f04..323abd442 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -34,19 +34,20 @@ 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, - RaiseStatement, RaiseStatementValue, ReferentialAction, RenameSelectItem, ReplaceSelectElement, - ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, SqlOption, Statement, Subscript, - SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor, TableObject, - TableOptionsClustered, TableWithJoins, Update, UpdateTableFromKind, Use, Value, Values, - ViewColumnDef, WhileStatement, WildcardAdditionalOptions, With, WithFill, + 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, TableOptionsClustered, TableWithJoins, Update, + UpdateTableFromKind, Use, Value, Values, ViewColumnDef, WhileStatement, + WildcardAdditionalOptions, With, WithFill, }; /// Given an iterator of spans, return the [Span::union] of all spans. @@ -554,8 +555,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 @@ -586,7 +587,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())), ) } } @@ -624,6 +627,33 @@ impl Spanned for TableConstraint { } } +impl Spanned for PartitionBoundValue { + fn span(&self) -> Span { + match self { + PartitionBoundValue::Expr(expr) => expr.span(), + // MINVALUE and MAXVALUE are keywords without tracked spans + 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())), + ), + // WITH (MODULUS n, REMAINDER r) - u64 values have no spans + ForValues::With { .. } => Span::empty(), + ForValues::Default => Span::empty(), + } + } +} + impl Spanned for CreateIndex { fn span(&self) -> Span { let CreateIndex {