From 3818d51cc3255d27c398692040ea5964e1247d4a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 09:03:23 +0100 Subject: [PATCH 1/3] PostgreSQL: Support PARTITION OF in CREATE FOREIGN TABLE --- ...gres_create_foreign_table_partition_of.sql | 2 + src/ast/ddl.rs | 27 +++++++-- src/parser/mod.rs | 29 +++++++++- tests/sqlparser_postgres.rs | 57 +++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 fuzz/fuzz_seeds/postgres_create_foreign_table_partition_of.sql diff --git a/fuzz/fuzz_seeds/postgres_create_foreign_table_partition_of.sql b/fuzz/fuzz_seeds/postgres_create_foreign_table_partition_of.sql new file mode 100644 index 0000000000..7a498720d9 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_create_foreign_table_partition_of.sql @@ -0,0 +1,2 @@ +CREATE FOREIGN TABLE part PARTITION OF root (id NOT NULL) FOR VALUES FROM (0) TO (10) SERVER s; +CREATE FOREIGN TABLE part2 PARTITION OF root DEFAULT SERVER s \ No newline at end of file diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 019414730f..60c4c003b6 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -6331,6 +6331,11 @@ pub struct CreateForeignTable { pub constraints: Vec, /// `INHERITS ( parent_table [, ...] )` pub inherits: Option>, + /// The parent table of `PARTITION OF parent_table`. + #[cfg_attr(feature = "visitor", visit(with = "visit_relation"))] + pub partition_of: Option, + /// The partition bound following `PARTITION OF`. + pub for_values: Option, /// The `SERVER server_name` clause. pub server_name: Ident, /// Optional `OPTIONS (key 'value', ...)` clause at the table level. @@ -6341,23 +6346,31 @@ impl fmt::Display for CreateForeignTable { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "CREATE FOREIGN TABLE {if_not_exists}{name} ({columns}", + "CREATE FOREIGN TABLE {if_not_exists}{name}", if_not_exists = if self.if_not_exists { "IF NOT EXISTS " } else { "" }, name = self.name, - columns = display_comma_separated(&self.columns), )?; - if !self.columns.is_empty() && !self.constraints.is_empty() { - write!(f, ", ")?; + if let Some(partition_of) = &self.partition_of { + write!(f, " PARTITION OF {partition_of}")?; + } + // The column list is optional only in the `PARTITION OF` form. + if self.partition_of.is_none() || !self.columns.is_empty() || !self.constraints.is_empty() { + write!(f, " ({}", display_comma_separated(&self.columns))?; + if !self.columns.is_empty() && !self.constraints.is_empty() { + write!(f, ", ")?; + } + write!(f, "{})", display_comma_separated(&self.constraints))?; } - write!(f, "{}", display_comma_separated(&self.constraints))?; - f.write_str(")")?; if let Some(inherits) = &self.inherits { write!(f, " INHERITS ({})", display_comma_separated(inherits))?; } + if let Some(for_values) = &self.for_values { + write!(f, " {for_values}")?; + } write!(f, " SERVER {}", self.server_name)?; if let Some(options) = &self.options { write!(f, " OPTIONS ({})", display_comma_separated(options))?; @@ -6435,6 +6448,8 @@ impl Spanned for CreateForeignTable { .chain(self.columns.iter().map(|column| column.span())) .chain(self.constraints.iter().map(|constraint| constraint.span())) .chain(self.inherits.iter().flatten().map(|parent| parent.span())) + .chain(self.partition_of.iter().map(|parent| parent.span())) + .chain(self.for_values.iter().map(|for_values| for_values.span())) .chain(core::iter::once(self.server_name.span)) .chain( self.options diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2289b27fea..9f47552d3b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -21461,24 +21461,45 @@ impl<'a> Parser<'a> { /// Parse a `CREATE FOREIGN TABLE` statement. /// - /// Per-column `OPTIONS ( ... )` and the `PARTITION OF` form are not parsed yet. + /// Per-column `OPTIONS ( ... )` are not parsed yet. /// /// 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)?; - if self.peek_token_ref().token != Token::LParen { + let partition_of = if self.parse_keywords(&[Keyword::PARTITION, Keyword::OF]) { + Some(self.parse_object_name(false)?) + } else { + None + }; + if partition_of.is_none() && self.peek_token_ref().token != Token::LParen { return self.expected_ref( "'(' before the column list of CREATE FOREIGN TABLE", self.peek_token_ref(), ); } let (columns, constraints) = self.parse_columns()?; - let inherits = if self.parse_keyword(Keyword::INHERITS) { + // `INHERITS` belongs to the column-list form only. + let inherits = if partition_of.is_none() && self.parse_keyword(Keyword::INHERITS) { Some(self.parse_parenthesized_qualified_column_list(IsOptional::Mandatory, false)?) } else { None }; + + let for_values = if partition_of.is_some() { + if self + .peek_one_of_keywords(&[Keyword::FOR, Keyword::DEFAULT]) + .is_none() + { + return self.expected_ref( + "FOR VALUES or DEFAULT after PARTITION OF", + self.peek_token_ref(), + ); + } + Some(self.parse_partition_for_values()?) + } else { + None + }; self.expect_keyword_is(Keyword::SERVER)?; let server_name = self.parse_identifier()?; let options = self.parse_pg_options_clause()?; @@ -21489,6 +21510,8 @@ impl<'a> Parser<'a> { columns, constraints, inherits, + partition_of, + for_values, server_name, options, }) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 9f85cd011e..83520d3276 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -12291,3 +12291,60 @@ fn parse_create_table_partition_of_untyped_columns() { ); } } + +#[test] +fn parse_create_foreign_table_partition_of() { + let sql = "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES FROM (0) TO (10) SERVER s"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + stmt.partition_of, + Some(ObjectName::from(vec![Ident::new("root")])) + ); + assert_eq!( + stmt.for_values, + Some(ForValues::From { + from: vec![PartitionBoundValue::Expr(Expr::value(number("0")))], + to: vec![PartitionBoundValue::Expr(Expr::value(number("10")))], + }) + ); + assert!(stmt.columns.is_empty()); + + for sql in [ + "CREATE FOREIGN TABLE IF NOT EXISTS part PARTITION OF root DEFAULT SERVER s OPTIONS (table_name 'remote')", + "CREATE FOREIGN TABLE part PARTITION OF root (CONSTRAINT positive CHECK (id > 0)) FOR VALUES IN (1, 2) SERVER s", + "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES WITH (MODULUS 4, REMAINDER 0) SERVER s", + "CREATE FOREIGN TABLE part PARTITION OF root (id NOT NULL) FOR VALUES IN (3) SERVER s", + ] { + pg_and_generic().verified_stmt(sql); + } + + for (sql, expected) in [ + ( + "CREATE FOREIGN TABLE part PARTITION OF root SERVER s", + "Expected: FOR VALUES or DEFAULT after PARTITION OF, found: SERVER", + ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root DEFAULT INHERITS (other) SERVER s", + "Expected: SERVER, found: INHERITS", + ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root INHERITS (other) FOR VALUES IN (1) SERVER s", + "Expected: FOR VALUES or DEFAULT after PARTITION OF, found: INHERITS", + ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES IN (1)", + "Expected: SERVER, found: EOF", + ), + ] { + assert_eq!( + pg_and_generic() + .parse_sql_statements(sql) + .unwrap_err() + .to_string(), + format!("sql parser error: {expected}"), + "{sql}" + ); + } +} From 2c29d91e916292bd85c0ba24fcd3863d7233bfdd Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 09:54:32 +0100 Subject: [PATCH 2/3] Test error paths and formatter errors --- tests/sqlparser_postgres.rs | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 83520d3276..4688996742 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -12311,6 +12311,20 @@ fn parse_create_foreign_table_partition_of() { ); assert!(stmt.columns.is_empty()); + let sql = "CREATE FOREIGN TABLE part PARTITION OF public.root (id NOT NULL, CONSTRAINT positive CHECK (id > 0)) FOR VALUES FROM (MINVALUE) TO (MAXVALUE) SERVER s"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.columns.len(), 1); + assert_eq!(stmt.constraints.len(), 1); + assert_eq!( + stmt.for_values, + Some(ForValues::From { + from: vec![PartitionBoundValue::MinValue], + to: vec![PartitionBoundValue::MaxValue], + }) + ); + for sql in [ "CREATE FOREIGN TABLE IF NOT EXISTS part PARTITION OF root DEFAULT SERVER s OPTIONS (table_name 'remote')", "CREATE FOREIGN TABLE part PARTITION OF root (CONSTRAINT positive CHECK (id > 0)) FOR VALUES IN (1, 2) SERVER s", @@ -12337,6 +12351,22 @@ fn parse_create_foreign_table_partition_of() { "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES IN (1)", "Expected: SERVER, found: EOF", ), + ( + "CREATE FOREIGN TABLE part PARTITION OF", + "Expected: identifier, found: EOF", + ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES IN () SERVER s", + "Expected: at least one value, found: )", + ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root FOR VALUES SERVER s", + "Expected: IN, FROM, or WITH after FOR VALUES, found: SERVER", + ), + ( + "CREATE FOREIGN TABLE part (id INT) PARTITION OF root DEFAULT SERVER s", + "Expected: SERVER, found: PARTITION", + ), ] { assert_eq!( pg_and_generic() @@ -12348,3 +12378,50 @@ fn parse_create_foreign_table_partition_of() { ); } } + +#[test] +#[cfg(feature = "visitor")] +fn visit_relations_create_foreign_table_partition_of() { + let stmts = pg() + .parse_sql_statements("CREATE FOREIGN TABLE part PARTITION OF public.root DEFAULT SERVER s") + .unwrap(); + let mut relations = vec![]; + let _ = sqlparser::ast::visit_relations(&stmts, |relation| { + relations.push(relation.to_string()); + core::ops::ControlFlow::<()>::Continue(()) + }); + assert_eq!(relations, ["part", "public.root"]); +} + +#[test] +fn display_create_foreign_table_partition_of_propagates_formatter_errors() { + use core::fmt::{self, Write}; + + struct RejectingWriter(&'static str); + + impl Write for RejectingWriter { + fn write_str(&mut self, value: &str) -> fmt::Result { + if value == self.0 { + Err(fmt::Error) + } else { + Ok(()) + } + } + } + + let stmt = pg_and_generic().verified_stmt( + "CREATE FOREIGN TABLE part PARTITION OF root (id NOT NULL, CONSTRAINT positive CHECK (id > 0)) FOR VALUES IN (1) SERVER s", + ); + for rejected in [ + "CREATE FOREIGN TABLE ", + " PARTITION OF ", + " (", + ", ", + ")", + "FOR VALUES IN (", + " SERVER ", + ] { + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{stmt}"), Err(fmt::Error), "{rejected:?}"); + } +} From 6cd5ba718ae221f648e4faf52373f8eaaa96c077 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 29 Oct 2026 15:02:11 +0100 Subject: [PATCH 3/3] Read untyped PARTITION OF columns in CREATE FOREIGN TABLE --- src/ast/spans.rs | 13 +++++++++++++ src/parser/mod.rs | 9 ++++++--- tests/sqlparser_postgres.rs | 25 ++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index ca48359557..2f42b1baf2 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -3400,6 +3400,19 @@ SETTINGS update_parallel_mode = 'auto'"#; assert_eq!(test.get_source(options[0].value.span), "'public'"); } + #[test] + fn test_create_foreign_table_partition_of_span() { + let dialect = &crate::dialect::PostgreSqlDialect {}; + let sql = + "CREATE FOREIGN TABLE part PARTITION OF root (id NOT NULL) FOR VALUES IN (1) SERVER s"; + let test = SpanTest::new(dialect, sql); + let r = Parser::parse_sql(dialect, sql).unwrap(); + assert_eq!( + test.get_source(r[0].span()), + "part PARTITION OF root (id NOT NULL) FOR VALUES IN (1) SERVER s" + ); + } + #[test] fn test_alter_table_modify_order_by_span() { let dialect = &crate::dialect::ClickHouseDialect {}; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9f47552d3b..a05a7ce2c1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -21472,13 +21472,16 @@ impl<'a> Parser<'a> { } else { None }; - if partition_of.is_none() && self.peek_token_ref().token != Token::LParen { + let (columns, constraints) = if partition_of.is_some() { + self.parse_partition_of_columns()? + } else if self.peek_token_ref().token == Token::LParen { + self.parse_columns()? + } else { return self.expected_ref( "'(' before the column list of CREATE FOREIGN TABLE", self.peek_token_ref(), ); - } - let (columns, constraints) = self.parse_columns()?; + }; // `INHERITS` belongs to the column-list form only. let inherits = if partition_of.is_none() && self.parse_keyword(Keyword::INHERITS) { Some(self.parse_parenthesized_qualified_column_list(IsOptional::Mandatory, false)?) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 4688996742..81a92a6d9c 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -12315,7 +12315,17 @@ fn parse_create_foreign_table_partition_of() { let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { unreachable!() }; - assert_eq!(stmt.columns.len(), 1); + assert_eq!( + stmt.columns, + vec![ColumnDef { + name: Ident::new("id"), + data_type: DataType::Unspecified, + options: vec![ColumnOptionDef { + name: None, + option: ColumnOption::NotNull, + }], + }] + ); assert_eq!(stmt.constraints.len(), 1); assert_eq!( stmt.for_values, @@ -12334,6 +12344,11 @@ fn parse_create_foreign_table_partition_of() { pg_and_generic().verified_stmt(sql); } + pg_and_generic().one_statement_parses_to( + "CREATE FOREIGN TABLE part PARTITION OF root (id WITH OPTIONS NOT NULL) DEFAULT SERVER s", + "CREATE FOREIGN TABLE part PARTITION OF root (id NOT NULL) DEFAULT SERVER s", + ); + for (sql, expected) in [ ( "CREATE FOREIGN TABLE part PARTITION OF root SERVER s", @@ -12367,6 +12382,14 @@ fn parse_create_foreign_table_partition_of() { "CREATE FOREIGN TABLE part (id INT) PARTITION OF root DEFAULT SERVER s", "Expected: SERVER, found: PARTITION", ), + ( + "CREATE FOREIGN TABLE part PARTITION OF root (id INT) DEFAULT SERVER s", + "Expected: ',' or ')' after column definition, found: INT", + ), + ( + "CREATE FOREIGN TABLE part (id) SERVER s", + "Expected: a data type name, found: )", + ), ] { assert_eq!( pg_and_generic()