From e5bc154d211b08fe102036f09bfdab5253de8c41 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Wed, 16 Sep 2026 10:25:55 +0900 Subject: [PATCH 1/5] feat(postgres): parse CREATE FOREIGN TABLE Table-level OPTIONS reuses the list parser lifted out of CREATE SERVER. --- src/ast/ddl.rs | 83 +++++++++++++++++++++++++++++++++---- src/ast/mod.rs | 12 +++++- src/ast/spans.rs | 1 + src/parser/mod.rs | 52 ++++++++++++++++++----- tests/sqlparser_postgres.rs | 60 +++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 66f2cad3eb..12fcf94a99 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -42,15 +42,15 @@ use crate::ast::{ UniqueConstraint, }, ArgMode, AttachedToken, CommentDef, ConditionalStatements, CreateFunctionBody, - CreateFunctionUsing, CreateTableLikeKind, CreateTableOptions, CreateViewParams, DataType, Expr, - FileFormat, FunctionBehavior, FunctionCalledOnNull, FunctionDefinitionSetParam, FunctionDesc, - FunctionDeterminismSpecifier, FunctionParallel, FunctionSecurity, HiveDistributionStyle, - HiveFormat, HiveIOFormat, HiveRowFormat, HiveSetLocation, Ident, InitializeKind, - MySQLColumnPosition, ObjectName, OnCommit, OneOrManyWithParens, OperateFunctionArg, - OrderByExpr, ProjectionSelect, Query, RefreshModeKind, ResetConfig, RowAccessPolicy, - SequenceOptions, Spanned, SqlOption, StorageLifecyclePolicy, StorageSerializationPolicy, - TableVersion, Tag, TriggerEvent, TriggerExecBody, TriggerObject, TriggerPeriod, - TriggerReferencing, Value, ValueWithSpan, WrappedCollection, + CreateFunctionUsing, CreateServerOption, CreateTableLikeKind, CreateTableOptions, + CreateViewParams, DataType, Expr, FileFormat, FunctionBehavior, FunctionCalledOnNull, + FunctionDefinitionSetParam, FunctionDesc, FunctionDeterminismSpecifier, FunctionParallel, + FunctionSecurity, HiveDistributionStyle, HiveFormat, HiveIOFormat, HiveRowFormat, + HiveSetLocation, Ident, InitializeKind, MySQLColumnPosition, ObjectName, OnCommit, + OneOrManyWithParens, OperateFunctionArg, OrderByExpr, ProjectionSelect, Query, RefreshModeKind, + ResetConfig, RowAccessPolicy, SequenceOptions, Spanned, SqlOption, StorageLifecyclePolicy, + StorageSerializationPolicy, TableVersion, Tag, TriggerEvent, TriggerExecBody, TriggerObject, + TriggerPeriod, TriggerReferencing, Value, ValueWithSpan, WrappedCollection, }; use crate::display_utils::{DisplayCommaSeparated, Indent, NewLine, SpaceOrNewline}; use crate::keywords::Keyword; @@ -5995,3 +5995,68 @@ impl From for crate::ast::Statement { crate::ast::Statement::AlterPolicy(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, + /// Table-level constraints (e.g. `CHECK (...)`, composite `FOREIGN KEY`). + /// PostgreSQL accepts these in `CREATE FOREIGN TABLE` column lists. + pub constraints: 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}", + 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, ", ")?; + } + write!(f, "{}", display_comma_separated(&self.constraints))?; + write!(f, ") SERVER {}", 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) + } +} + +impl Spanned for CreateForeignTable { + fn span(&self) -> Span { + Span::union_iter( + core::iter::once(self.name.span()) + .chain(self.columns.iter().map(|column| column.span())) + .chain(self.constraints.iter().map(|constraint| constraint.span())) + .chain(core::iter::once(self.server_name.span)), + ) + } +} diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 20058b83ab..24cb75f05d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -72,7 +72,7 @@ pub use self::ddl::{ AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, ColumnPolicyProperty, ConstraintCharacteristics, CreateCollation, CreateCollationDefinition, CreateConnector, - CreateDomain, CreateExtension, CreateFunction, CreateIndex, CreateOperator, + CreateDomain, CreateExtension, CreateForeignTable, CreateFunction, CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, CreateTable, CreateTextSearch, CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, DropOperatorClass, @@ -3773,6 +3773,11 @@ pub enum Statement { /// A `CREATE SERVER` statement. CreateServer(CreateServerStatement), /// ```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) @@ -5632,6 +5637,7 @@ impl fmt::Display for Statement { Statement::CreateServer(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), @@ -9189,7 +9195,9 @@ impl fmt::Display for CreateServerStatement { } } -/// A key/value option for `CREATE SERVER`. +/// A key/value entry in a Postgres `OPTIONS ( ... )` clause. The name is +/// historical (introduced in 0.62.0 for `CREATE SERVER`); it is now the +/// shared OPTIONS element for `CREATE SERVER` and `CREATE FOREIGN TABLE`. #[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/spans.rs b/src/ast/spans.rs index 7acbd7d0b4..be349e8cc0 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -394,6 +394,7 @@ impl Spanned for Statement { Statement::DropOperatorClass(drop_operator_class) => drop_operator_class.span(), Statement::CreateSecret { .. } => Span::empty(), Statement::CreateServer { .. } => Span::empty(), + Statement::CreateForeignTable(stmt) => stmt.span(), Statement::CreateConnector { .. } => Span::empty(), Statement::CreateOperator(create_operator) => create_operator.span(), Statement::CreateOperatorFamily(create_operator_family) => { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 15f135fffa..6cf563ca72 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5359,6 +5359,8 @@ impl<'a> Parser<'a> { } } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() + } else if self.parse_keywords(&[Keyword::FOREIGN, Keyword::TABLE]) { + self.parse_create_foreign_table().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -20431,16 +20433,7 @@ impl<'a> Parser<'a> { self.expect_keywords(&[Keyword::FOREIGN, Keyword::DATA, Keyword::WRAPPER])?; let foreign_data_wrapper = self.parse_object_name(false)?; - let mut options = None; - if self.parse_keyword(Keyword::OPTIONS) { - self.expect_token(&Token::LParen)?; - options = Some(self.parse_comma_separated(|p| { - let key = p.parse_identifier()?; - let value = p.parse_identifier()?; - Ok(CreateServerOption { key, value }) - })?); - self.expect_token(&Token::RParen)?; - } + let options = self.parse_generic_options_clause()?; Ok(Statement::CreateServer(CreateServerStatement { name, @@ -20452,6 +20445,45 @@ impl<'a> Parser<'a> { })) } + /// Parse an optional `OPTIONS ( key value [, ...] )` clause shared by + /// `CREATE SERVER` and `CREATE FOREIGN TABLE`. + fn parse_generic_options_clause( + &mut self, + ) -> Result>, ParserError> { + if !self.parse_keyword(Keyword::OPTIONS) { + return Ok(None); + } + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(|p| { + let key = p.parse_identifier()?; + let value = p.parse_identifier()?; + Ok(CreateServerOption { key, value }) + })?; + self.expect_token(&Token::RParen)?; + Ok(Some(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 = self.parse_generic_options_clause()?; + + Ok(CreateForeignTable { + name, + if_not_exists, + columns, + constraints, + server_name, + 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 d71e49b27a..b843294707 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9639,6 +9639,66 @@ fn parse_lock_table() { } } +#[test] +fn parse_create_foreign_table() { + let sql = "CREATE FOREIGN TABLE ft1 (id INTEGER, name TEXT) SERVER myserver"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().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()); + + let sql = "CREATE FOREIGN TABLE IF NOT EXISTS ft2 (col INTEGER) SERVER remoteserver"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert!(stmt.if_not_exists); + assert_eq!(stmt.name.to_string(), "ft2"); + + let sql = + "CREATE FOREIGN TABLE ft3 (col INTEGER) SERVER remoteserver OPTIONS (schema_name 'public')"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + stmt.options, + Some(vec![CreateServerOption { + key: "schema_name".into(), + value: Ident { + value: "public".to_string(), + quote_style: Some('\''), + span: Span::empty(), + }, + }]) + ); +} + +#[test] +fn parse_create_foreign_table_with_check_constraint() { + // PostgreSQL accepts table-level CHECK constraints in CREATE FOREIGN TABLE. + // The constraint must round-trip rather than being silently dropped. + let sql = + "CREATE FOREIGN TABLE ft (id INTEGER, CONSTRAINT id_positive CHECK (id > 0)) 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); + + // Zero columns with only a table-level constraint must not emit `(, CONSTRAINT ...)`. + let sql = "CREATE FOREIGN TABLE ft (CONSTRAINT c CHECK (id > 0)) SERVER s"; + let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(stmt.columns.len(), 0); + assert_eq!(stmt.constraints.len(), 1); +} + #[test] fn exclude_as_column_name() { // `EXCLUDE` is a non-reserved keyword, so it stays usable as a column name From 23ce3a9771c3dc1290fde88c441abb98897506b0 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Wed, 16 Sep 2026 10:51:17 +0900 Subject: [PATCH 2/5] fix(postgres): require the foreign table column list Without parens Display emitted a () the input never had, and a persistence modifier was accepted then dropped. Also covers OPTIONS in the span. --- src/ast/ddl.rs | 11 +++++++++-- src/ast/mod.rs | 4 +--- src/parser/mod.rs | 26 +++++++++++++++++++------- tests/sqlparser_postgres.rs | 31 ++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 12fcf94a99..44dd1b9c68 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -6011,7 +6011,8 @@ pub struct CreateForeignTable { /// Column definitions. pub columns: Vec, /// Table-level constraints (e.g. `CHECK (...)`, composite `FOREIGN KEY`). - /// PostgreSQL accepts these in `CREATE FOREIGN TABLE` column lists. + /// PostgreSQL's grammar accepts these here, but rejects primary key, unique, + /// foreign key and exclusion constraints on a foreign table at execution. pub constraints: Vec, /// The `SERVER server_name` clause. pub server_name: Ident, @@ -6056,7 +6057,13 @@ impl Spanned for CreateForeignTable { core::iter::once(self.name.span()) .chain(self.columns.iter().map(|column| column.span())) .chain(self.constraints.iter().map(|constraint| constraint.span())) - .chain(core::iter::once(self.server_name.span)), + .chain(core::iter::once(self.server_name.span)) + .chain( + self.options + .iter() + .flatten() + .flat_map(|option| [option.key.span, option.value.span]), + ), ) } } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 24cb75f05d..bd0cfd5072 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -9195,9 +9195,7 @@ impl fmt::Display for CreateServerStatement { } } -/// A key/value entry in a Postgres `OPTIONS ( ... )` clause. The name is -/// historical (introduced in 0.62.0 for `CREATE SERVER`); it is now the -/// shared OPTIONS element for `CREATE SERVER` and `CREATE FOREIGN TABLE`. +/// A key/value entry in a Postgres `OPTIONS ( ... )` clause. #[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/parser/mod.rs b/src/parser/mod.rs index 6cf563ca72..f89345b89e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5360,6 +5360,12 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() } else if self.parse_keywords(&[Keyword::FOREIGN, Keyword::TABLE]) { + if temporary || global.is_some() || transient || volatile { + return parser_err!( + "CREATE FOREIGN TABLE does not accept a persistence modifier", + self.peek_token_ref().span.start + ); + } self.parse_create_foreign_table().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) @@ -20433,7 +20439,7 @@ impl<'a> Parser<'a> { self.expect_keywords(&[Keyword::FOREIGN, Keyword::DATA, Keyword::WRAPPER])?; let foreign_data_wrapper = self.parse_object_name(false)?; - let options = self.parse_generic_options_clause()?; + let options = self.parse_pg_options_clause()?; Ok(Statement::CreateServer(CreateServerStatement { name, @@ -20445,11 +20451,8 @@ impl<'a> Parser<'a> { })) } - /// Parse an optional `OPTIONS ( key value [, ...] )` clause shared by - /// `CREATE SERVER` and `CREATE FOREIGN TABLE`. - fn parse_generic_options_clause( - &mut self, - ) -> Result>, ParserError> { + /// Parse an optional Postgres `OPTIONS ( key value [, ...] )` clause. + fn parse_pg_options_clause(&mut self) -> Result>, ParserError> { if !self.parse_keyword(Keyword::OPTIONS) { return Ok(None); } @@ -20465,14 +20468,23 @@ impl<'a> Parser<'a> { /// Parse a `CREATE FOREIGN TABLE` statement. /// + /// Per-column `OPTIONS ( ... )`, `INHERITS`, and the `PARTITION OF` form 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 { + return self.expected_ref( + "'(' before the column list of CREATE FOREIGN TABLE", + self.peek_token_ref(), + ); + } let (columns, constraints) = self.parse_columns()?; self.expect_keyword_is(Keyword::SERVER)?; let server_name = self.parse_identifier()?; - let options = self.parse_generic_options_clause()?; + let options = self.parse_pg_options_clause()?; Ok(CreateForeignTable { name, diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index b843294707..c5a512e7d3 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9645,12 +9645,7 @@ fn parse_create_foreign_table() { let Statement::CreateForeignTable(stmt) = pg_and_generic().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()); let sql = "CREATE FOREIGN TABLE IF NOT EXISTS ft2 (col INTEGER) SERVER remoteserver"; @@ -9658,7 +9653,6 @@ fn parse_create_foreign_table() { unreachable!() }; assert!(stmt.if_not_exists); - assert_eq!(stmt.name.to_string(), "ft2"); let sql = "CREATE FOREIGN TABLE ft3 (col INTEGER) SERVER remoteserver OPTIONS (schema_name 'public')"; @@ -9678,10 +9672,33 @@ fn parse_create_foreign_table() { ); } +#[test] +fn parse_create_foreign_table_requires_column_list() { + // Without the parens Display would invent a `()` the input never had. + assert!(matches!( + pg_and_generic().parse_sql_statements("CREATE FOREIGN TABLE ft SERVER s"), + Err(ParserError::ParserError(_)) + )); +} + +#[test] +fn parse_create_foreign_table_rejects_persistence_modifier() { + // There is no temporary or unlogged foreign table in PostgreSQL, and the + // modifier has no field to round-trip through. + for sql in [ + "CREATE TEMPORARY FOREIGN TABLE ft (a INT) SERVER s", + "CREATE GLOBAL FOREIGN TABLE ft (a INT) SERVER s", + ] { + assert!(matches!( + pg_and_generic().parse_sql_statements(sql), + Err(ParserError::ParserError(_)) + )); + } +} + #[test] fn parse_create_foreign_table_with_check_constraint() { // PostgreSQL accepts table-level CHECK constraints in CREATE FOREIGN TABLE. - // The constraint must round-trip rather than being silently dropped. let sql = "CREATE FOREIGN TABLE ft (id INTEGER, CONSTRAINT id_positive CHECK (id > 0)) SERVER s"; let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { From 894210ee8548a0fa05f2612084a0b212f1c7697d Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Wed, 16 Sep 2026 11:03:34 +0900 Subject: [PATCH 3/5] fix(postgres): reject every CREATE modifier on a foreign table OR ALTER, MULTISET, PERSISTENT and the view params were still accepted and then dropped. The error now points at the modifier rather than the table name. --- src/ast/spans.rs | 15 +++++++++++++++ src/parser/mod.rs | 15 ++++++++++++--- tests/sqlparser_postgres.rs | 25 ++++++++++++++++++------- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index be349e8cc0..4c9a042b05 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -3164,4 +3164,19 @@ WHERE id = 1 Span::new(Location::new(2, 8), Location::new(4, 52)) ); } + + #[test] + fn test_create_foreign_table_span_covers_options() { + let dialect = &crate::dialect::PostgreSqlDialect {}; + let sql = "CREATE FOREIGN TABLE ft (a INT) SERVER s OPTIONS (schema_name 'public')"; + let mut test = SpanTest::new(dialect, sql); + + // Ends at the option key, not the statement: a quoted option value is an + // Ident with an empty span, so it contributes nothing to the union. + let stmt = test.0.parse_statement().unwrap(); + assert_eq!( + test.get_source(stmt.span()), + "ft (a INT) SERVER s OPTIONS (schema_name" + ); + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f89345b89e..cce54c5fc0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5257,6 +5257,7 @@ impl<'a> Parser<'a> { /// Parse a SQL CREATE statement pub fn parse_create(&mut self) -> Result { + let modifier_loc = self.peek_token_ref().span.start; let or_replace = self.parse_keywords(&[Keyword::OR, Keyword::REPLACE]); let or_alter = self.parse_keywords(&[Keyword::OR, Keyword::ALTER]); let multiset = self.maybe_parse_multiset(); @@ -5360,10 +5361,18 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() } else if self.parse_keywords(&[Keyword::FOREIGN, Keyword::TABLE]) { - if temporary || global.is_some() || transient || volatile { + if temporary + || global.is_some() + || transient + || volatile + || or_alter + || multiset.is_some() + || persistent + || create_view_params.is_some() + { return parser_err!( - "CREATE FOREIGN TABLE does not accept a persistence modifier", - self.peek_token_ref().span.start + "CREATE FOREIGN TABLE does not accept this modifier", + modifier_loc ); } self.parse_create_foreign_table().map(Into::into) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index c5a512e7d3..6d5556cfbc 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9645,6 +9645,7 @@ fn parse_create_foreign_table() { let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { unreachable!() }; + assert!(!stmt.if_not_exists); assert_eq!(stmt.columns.len(), 2); assert!(stmt.options.is_none()); @@ -9679,20 +9680,30 @@ fn parse_create_foreign_table_requires_column_list() { pg_and_generic().parse_sql_statements("CREATE FOREIGN TABLE ft SERVER s"), Err(ParserError::ParserError(_)) )); + + // An empty list is still legal PostgreSQL. + pg_and_generic().verified_stmt("CREATE FOREIGN TABLE ft () SERVER s"); } #[test] -fn parse_create_foreign_table_rejects_persistence_modifier() { - // There is no temporary or unlogged foreign table in PostgreSQL, and the - // modifier has no field to round-trip through. +fn parse_create_foreign_table_rejects_modifiers() { + // None of these has a field on CreateForeignTable, so accepting one would + // drop it silently on the way back out through Display. for sql in [ "CREATE TEMPORARY FOREIGN TABLE ft (a INT) SERVER s", "CREATE GLOBAL FOREIGN TABLE ft (a INT) SERVER s", + "CREATE TRANSIENT FOREIGN TABLE ft (a INT) SERVER s", + "CREATE VOLATILE FOREIGN TABLE ft (a INT) SERVER s", + "CREATE OR ALTER FOREIGN TABLE ft (a INT) SERVER s", + "CREATE MULTISET FOREIGN TABLE ft (a INT) SERVER s", ] { - assert!(matches!( - pg_and_generic().parse_sql_statements(sql), - Err(ParserError::ParserError(_)) - )); + assert!( + matches!( + pg_and_generic().parse_sql_statements(sql), + Err(ParserError::ParserError(_)) + ), + "should have been rejected: {sql}" + ); } } From 98c5737bc86d52c1a92d2fa690cc3ab55e078772 Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Wed, 16 Sep 2026 16:55:14 +0900 Subject: [PATCH 4/5] test(postgres): pin the rejected-modifier error and cover the rest Matching any ParserError would also pass if the guard stopped being reached. LOCAL, SET and the view params were never exercised. --- src/ast/spans.rs | 2 +- src/parser/mod.rs | 5 +++-- tests/sqlparser_postgres.rs | 20 ++++++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 4c9a042b05..250b0414b9 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -3166,7 +3166,7 @@ WHERE id = 1 } #[test] - fn test_create_foreign_table_span_covers_options() { + fn test_create_foreign_table_span_includes_option_keys() { let dialect = &crate::dialect::PostgreSqlDialect {}; let sql = "CREATE FOREIGN TABLE ft (a INT) SERVER s OPTIONS (schema_name 'public')"; let mut test = SpanTest::new(dialect, sql); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index cce54c5fc0..834a206705 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5361,11 +5361,12 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() } else if self.parse_keywords(&[Keyword::FOREIGN, Keyword::TABLE]) { - if temporary + if or_replace + || or_alter + || temporary || global.is_some() || transient || volatile - || or_alter || multiset.is_some() || persistent || create_view_params.is_some() diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 6d5556cfbc..677a98d8f1 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9645,7 +9645,6 @@ fn parse_create_foreign_table() { let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { unreachable!() }; - assert!(!stmt.if_not_exists); assert_eq!(stmt.columns.len(), 2); assert!(stmt.options.is_none()); @@ -9692,19 +9691,28 @@ fn parse_create_foreign_table_rejects_modifiers() { for sql in [ "CREATE TEMPORARY FOREIGN TABLE ft (a INT) SERVER s", "CREATE GLOBAL FOREIGN TABLE ft (a INT) SERVER s", + "CREATE LOCAL FOREIGN TABLE ft (a INT) SERVER s", "CREATE TRANSIENT FOREIGN TABLE ft (a INT) SERVER s", "CREATE VOLATILE FOREIGN TABLE ft (a INT) SERVER s", "CREATE OR ALTER FOREIGN TABLE ft (a INT) SERVER s", "CREATE MULTISET FOREIGN TABLE ft (a INT) SERVER s", + "CREATE SET FOREIGN TABLE ft (a INT) SERVER s", + "CREATE ALGORITHM = UNDEFINED FOREIGN TABLE ft (a INT) SERVER s", ] { + let err = pg_and_generic().parse_sql_statements(sql).unwrap_err(); assert!( - matches!( - pg_and_generic().parse_sql_statements(sql), - Err(ParserError::ParserError(_)) - ), - "should have been rejected: {sql}" + err.to_string() + .contains("CREATE FOREIGN TABLE does not accept this modifier"), + "unexpected error for {sql}: {err}" ); } + + // OR REPLACE is caught by an earlier arm, so it never reaches the guard. + assert!(matches!( + pg_and_generic() + .parse_sql_statements("CREATE OR REPLACE FOREIGN TABLE ft (a INT) SERVER s"), + Err(ParserError::ParserError(_)) + )); } #[test] From 3e6f5ee39630be4e78845a1db4f750d1d373620c Mon Sep 17 00:00:00 2001 From: Filipe Guerreiro Date: Wed, 16 Sep 2026 17:07:28 +0900 Subject: [PATCH 5/5] test(postgres): drop asserts the round-trip already proves Also says why the or_replace disjunct is kept despite being unreachable today. --- src/parser/mod.rs | 2 ++ tests/sqlparser_postgres.rs | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 834a206705..93c86b75f9 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -5361,6 +5361,8 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::SERVER) { self.parse_pg_create_server() } else if self.parse_keywords(&[Keyword::FOREIGN, Keyword::TABLE]) { + // `or_replace` cannot reach here today, since the arm above catches it. + // It stays so that reordering the arms cannot make it fall through. if or_replace || or_alter || temporary diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 677a98d8f1..8e4d59cd14 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9641,18 +9641,18 @@ fn parse_lock_table() { #[test] fn parse_create_foreign_table() { - let sql = "CREATE FOREIGN TABLE ft1 (id INTEGER, name TEXT) SERVER myserver"; - let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { - unreachable!() - }; - assert_eq!(stmt.columns.len(), 2); - assert!(stmt.options.is_none()); - - let sql = "CREATE FOREIGN TABLE IF NOT EXISTS ft2 (col INTEGER) SERVER remoteserver"; - let Statement::CreateForeignTable(stmt) = pg_and_generic().verified_stmt(sql) else { - unreachable!() - }; - assert!(stmt.if_not_exists); + // Each of these round-trips through Display, so verified_stmt already pins + // the name, columns, server and IF NOT EXISTS. Only the parsed shape that + // Display cannot show is asserted below. + for sql in [ + "CREATE FOREIGN TABLE ft1 (id INTEGER, name TEXT) SERVER myserver", + "CREATE FOREIGN TABLE IF NOT EXISTS ft2 (col INTEGER) SERVER remoteserver", + ] { + assert!(matches!( + pg_and_generic().verified_stmt(sql), + Statement::CreateForeignTable(_) + )); + } let sql = "CREATE FOREIGN TABLE ft3 (col INTEGER) SERVER remoteserver OPTIONS (schema_name 'public')";