From 5af43f51e7acbade186fd41bc023cde395628c0b Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 08:44:26 +0100 Subject: [PATCH 1/4] PostgreSQL: Support ALTER FOREIGN TABLE --- .../postgres_alter_foreign_table.sql | 1 + src/ast/ddl.rs | 65 +++++++++++ src/ast/mod.rs | 45 ++++---- src/ast/spans.rs | 75 +++++++++---- src/parser/mod.rs | 63 ++++++++--- tests/sqlparser_common.rs | 2 +- tests/sqlparser_postgres.rs | 101 ++++++++++++++++++ 7 files changed, 294 insertions(+), 58 deletions(-) create mode 100644 fuzz/fuzz_seeds/postgres_alter_foreign_table.sql diff --git a/fuzz/fuzz_seeds/postgres_alter_foreign_table.sql b/fuzz/fuzz_seeds/postgres_alter_foreign_table.sql new file mode 100644 index 0000000000..53ff81da23 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_alter_foreign_table.sql @@ -0,0 +1 @@ +ALTER FOREIGN TABLE IF EXISTS ONLY public.ft OWNER TO app, OPTIONS (ADD fetch_size '100', SET batch_size '10', DROP updatable), ALTER COLUMN id OPTIONS (column_name 'remote_id') \ No newline at end of file diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 1247826c9f..f84148c143 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -560,6 +560,14 @@ pub enum AlterTableOperation { /// Parenthesized options supplied to `SET (...)`. options: Vec, }, + /// `OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ...] )`, changing the + /// options of a foreign table. + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterforeigntable.html) + GenericOptions { + /// The option changes, in source order. + options: Vec, + }, } /// An `ALTER Policy` (`Statement::AlterPolicy`) operation @@ -1122,6 +1130,9 @@ impl fmt::Display for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { write!(f, "SET ({})", display_comma_separated(options)) } + AlterTableOperation::GenericOptions { options } => { + write!(f, "OPTIONS ({})", display_comma_separated(options)) + } } } } @@ -1375,6 +1386,14 @@ pub enum AlterColumnOperation { /// Optional sequence options for identity generation. sequence_options: Option>, }, + /// `OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ...] )`, changing the + /// options of a foreign table column. + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterforeigntable.html) + GenericOptions { + /// The option changes, in source order. + options: Vec, + }, } impl fmt::Display for AlterColumnOperation { @@ -1424,6 +1443,48 @@ impl fmt::Display for AlterColumnOperation { } Ok(()) } + AlterColumnOperation::GenericOptions { options } => { + write!(f, "OPTIONS ({})", display_comma_separated(options)) + } + } + } +} + +/// An element of a PostgreSQL `OPTIONS ( [ ADD | SET | DROP ] option ['value'] [, ...] )` +/// list in an `ALTER` statement. +/// +/// [PostgreSQL](https://www.postgresql.org/docs/current/sql-alterforeigntable.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 AlterGenericOption { + /// `[ ADD ] option 'value'`, which fails if the option is already set. + Add { + /// The option to add. + option: CreateServerOption, + /// Whether the `ADD` keyword was written. + add_keyword: bool, + }, + /// `SET option 'value'`, which fails if the option is not set. + Set(CreateServerOption), + /// `DROP option` + Drop(Ident), +} + +impl fmt::Display for AlterGenericOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AlterGenericOption::Add { + option, + add_keyword, + } => { + if *add_keyword { + f.write_str("ADD ")?; + } + write!(f, "{option}") + } + AlterGenericOption::Set(option) => write!(f, "SET {option}"), + AlterGenericOption::Drop(key) => write!(f, "DROP {key}"), } } } @@ -4850,6 +4911,9 @@ pub enum AlterTableType { /// External table type /// External, + /// Foreign table type + /// + Foreign, } /// ALTER TABLE statement @@ -4884,6 +4948,7 @@ impl fmt::Display for AlterTable { Some(AlterTableType::Iceberg) => write!(f, "ALTER ICEBERG TABLE ")?, Some(AlterTableType::Dynamic) => write!(f, "ALTER DYNAMIC TABLE ")?, Some(AlterTableType::External) => write!(f, "ALTER EXTERNAL TABLE ")?, + Some(AlterTableType::Foreign) => write!(f, "ALTER FOREIGN TABLE ")?, None => write!(f, "ALTER TABLE ")?, } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index c06e120aee..022f0c43eb 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -65,28 +65,29 @@ pub use self::dcl::{ pub use self::ddl::{ AggregateModifyKind, Alignment, AlterCollation, AlterCollationOperation, AlterColumnOperation, AlterConnectorOwner, AlterFunction, AlterFunctionAction, AlterFunctionKind, - AlterFunctionOperation, AlterIndexOperation, AlterOperator, AlterOperatorClass, - AlterOperatorClassOperation, AlterOperatorFamily, AlterOperatorFamilyOperation, - AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, AlterSchema, AlterSchemaOperation, - AlterTable, AlterTableAlgorithm, AlterTableLock, AlterTableOperation, AlterTableType, - AlterTextSearch, AlterTextSearchOperation, AlterTextSearchOption, AlterTrigger, - AlterTriggerOperation, AlterType, AlterTypeAddValue, AlterTypeAddValuePosition, - AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, ClusteredBy, ColumnDef, - ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, ColumnPolicyProperty, - ConstraintCharacteristics, CreateAggregate, CreateAggregateArgs, CreateAggregateOption, - CreateCollation, CreateCollationDefinition, CreateConnector, CreateDomain, CreateExtension, - CreateForeignDataWrapper, CreateForeignTable, CreateFunction, CreateIndex, CreateOperator, - CreateOperatorClass, CreateOperatorFamily, CreatePolicy, CreatePolicyCommand, CreatePolicyType, - CreateRule, CreateRuleAction, CreateRuleDoKind, CreateRuleEvent, CreateTable, CreateTextSearch, - CreateTrigger, CreateView, Deduplicate, DeferrableInitial, DistStyle, DropBehavior, - DropExtension, DropFunction, DropOperator, DropOperatorClass, DropOperatorFamily, - DropOperatorSignature, DropPolicy, DropTrigger, ForValues, ForeignDataWrapperRoutineClause, - FunctionReturnType, GeneratedAs, GeneratedExpressionMode, IdentityParameters, IdentityProperty, - IdentityPropertyFormatKind, IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, - IndexOption, IndexType, KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, - OperatorClassItem, OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, - Owner, Partition, PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, - ReplicaIdentity, SqliteTableOption, SqliteTableOptions, TagsColumnOption, TextSearchObjectType, + AlterFunctionOperation, AlterGenericOption, AlterIndexOperation, AlterOperator, + AlterOperatorClass, AlterOperatorClassOperation, AlterOperatorFamily, + AlterOperatorFamilyOperation, AlterOperatorOperation, AlterPolicy, AlterPolicyOperation, + AlterSchema, AlterSchemaOperation, AlterTable, AlterTableAlgorithm, AlterTableLock, + AlterTableOperation, AlterTableType, AlterTextSearch, AlterTextSearchOperation, + AlterTextSearchOption, AlterTrigger, AlterTriggerOperation, AlterType, AlterTypeAddValue, + AlterTypeAddValuePosition, AlterTypeOperation, AlterTypeRename, AlterTypeRenameValue, + ClusteredBy, ColumnDef, ColumnOption, ColumnOptionDef, ColumnOptions, ColumnPolicy, + ColumnPolicyProperty, ConstraintCharacteristics, CreateAggregate, CreateAggregateArgs, + CreateAggregateOption, CreateCollation, CreateCollationDefinition, CreateConnector, + CreateDomain, CreateExtension, CreateForeignDataWrapper, CreateForeignTable, CreateFunction, + CreateIndex, CreateOperator, CreateOperatorClass, CreateOperatorFamily, CreatePolicy, + CreatePolicyCommand, CreatePolicyType, CreateRule, CreateRuleAction, CreateRuleDoKind, + CreateRuleEvent, CreateTable, CreateTextSearch, CreateTrigger, CreateView, Deduplicate, + DeferrableInitial, DistStyle, DropBehavior, DropExtension, DropFunction, DropOperator, + DropOperatorClass, DropOperatorFamily, DropOperatorSignature, DropPolicy, DropTrigger, + ForValues, ForeignDataWrapperRoutineClause, FunctionReturnType, GeneratedAs, + GeneratedExpressionMode, IdentityParameters, IdentityProperty, IdentityPropertyFormatKind, + IdentityPropertyKind, IdentityPropertyOrder, IndexColumn, IndexOption, IndexType, + KeyOrIndexDisplay, Msck, NullsDistinctOption, OperatorArgTypes, OperatorClassItem, + OperatorFamilyDropItem, OperatorFamilyItem, OperatorOption, OperatorPurpose, Owner, Partition, + PartitionBoundValue, ProcedureParam, ReferentialAction, RenameTableNameKind, ReplicaIdentity, + SqliteTableOption, SqliteTableOptions, TagsColumnOption, TextSearchObjectType, TriggerObjectKind, Truncate, UserDefinedTypeCompositeAttributeDef, UserDefinedTypeInternalLength, UserDefinedTypeRangeOption, UserDefinedTypeRepresentation, UserDefinedTypeSqlDefinitionOption, UserDefinedTypeStorage, ViewColumnDef, WithData, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 704b583b8b..b032489a03 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -29,26 +29,27 @@ use crate::tokenizer::Span; use super::{ comments, dcl::SecondaryRoles, value::ValueWithSpan, AccessExpr, AlterColumnOperation, - AlterIndexOperation, AlterTableOperation, AlterTrigger, AlterTriggerOperation, Analyze, Array, - Assignment, AssignmentTarget, AttachedToken, BeginEndStatements, CaseStatement, CloseCursor, - ClusteredIndex, ColumnDef, ColumnOption, ColumnOptionDef, ConditionalStatementBlock, - ConditionalStatements, ConflictTarget, ConnectByKind, ConstraintCharacteristics, CopySource, - CreateIndex, CreateRule, CreateRuleAction, CreateTable, CreateTableOptions, Cte, Delete, - DoUpdate, ExceptSelectItem, ExcludeConstraintElement, ExcludeSelectItem, Expr, ExprWithAlias, - Fetch, ForValues, FromTable, Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, - FunctionArgumentList, FunctionArguments, GroupByExpr, HavingBound, IfStatement, - IlikeSelectItem, InTarget, IndexColumn, Insert, Interpolate, InterpolateExpr, Join, - JoinConstraint, JoinOperator, JsonPath, JsonPathElem, LateralView, LimitClause, - MatchRecognizePattern, Measure, Merge, MergeAction, MergeClause, MergeInsertExpr, - MergeInsertKind, MergeUpdateExpr, MergeUpdateKind, NamedParenthesizedList, - NamedWindowDefinition, ObjectName, ObjectNamePart, Offset, OnConflict, OnConflictAction, - OnInsert, OpenStatement, OperateFunctionArg, OrderBy, OrderByExpr, OrderByKind, OutputClause, - Parens, Partition, PartitionBoundValue, PivotValueSource, ProjectionSelect, Query, - RaiseStatement, RaiseStatementValue, ReferentialAction, RenameSelectItem, ReplaceSelectElement, - ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, Setting, SqlOption, Statement, - Subscript, SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor, - TableObject, TableOptionsClustered, TableWithJoins, Update, UpdateTableFromKind, Use, Values, - ViewColumnDef, WhileStatement, WildcardAdditionalOptions, With, WithFill, + AlterGenericOption, AlterIndexOperation, AlterTableOperation, AlterTrigger, + AlterTriggerOperation, Analyze, Array, Assignment, AssignmentTarget, AttachedToken, + BeginEndStatements, CaseStatement, CloseCursor, ClusteredIndex, ColumnDef, ColumnOption, + ColumnOptionDef, ConditionalStatementBlock, ConditionalStatements, ConflictTarget, + ConnectByKind, ConstraintCharacteristics, CopySource, CreateIndex, CreateRule, + CreateRuleAction, CreateTable, CreateTableOptions, Cte, Delete, DoUpdate, ExceptSelectItem, + ExcludeConstraintElement, ExcludeSelectItem, Expr, ExprWithAlias, Fetch, ForValues, FromTable, + Function, FunctionArg, FunctionArgExpr, FunctionArgumentClause, FunctionArgumentList, + FunctionArguments, GroupByExpr, HavingBound, IfStatement, IlikeSelectItem, InTarget, + IndexColumn, Insert, Interpolate, InterpolateExpr, Join, JoinConstraint, JoinOperator, + JsonPath, JsonPathElem, LateralView, LimitClause, MatchRecognizePattern, Measure, Merge, + MergeAction, MergeClause, MergeInsertExpr, MergeInsertKind, MergeUpdateExpr, MergeUpdateKind, + NamedParenthesizedList, NamedWindowDefinition, ObjectName, ObjectNamePart, Offset, OnConflict, + OnConflictAction, OnInsert, OpenStatement, OperateFunctionArg, OrderBy, OrderByExpr, + OrderByKind, OutputClause, Parens, Partition, PartitionBoundValue, PivotValueSource, + ProjectionSelect, Query, RaiseStatement, RaiseStatementValue, ReferentialAction, + RenameSelectItem, ReplaceSelectElement, ReplaceSelectItem, Select, SelectInto, SelectItem, + SetExpr, Setting, SqlOption, Statement, Subscript, SymbolDefinition, TableAlias, + TableAliasColumnDef, TableConstraint, TableFactor, TableObject, TableOptionsClustered, + TableWithJoins, Update, UpdateTableFromKind, Use, Values, ViewColumnDef, WhileStatement, + WildcardAdditionalOptions, With, WithFill, }; /// Given an iterator of spans, return the [Span::union] of all spans. @@ -966,6 +967,20 @@ impl Spanned for AlterColumnOperation { had_set: _, } => using.as_ref().map_or(Span::empty(), |u| u.span()), AlterColumnOperation::AddGenerated { .. } => Span::empty(), + AlterColumnOperation::GenericOptions { options } => { + union_spans(options.iter().map(|option| option.span())) + } + } + } +} + +impl Spanned for AlterGenericOption { + fn span(&self) -> Span { + match self { + AlterGenericOption::Add { option, .. } | AlterGenericOption::Set(option) => { + option.key.span.union(&option.value.span) + } + AlterGenericOption::Drop(key) => key.span, } } } @@ -1312,6 +1327,9 @@ impl Spanned for AlterTableOperation { AlterTableOperation::SetOptionsParens { options } => { union_spans(options.iter().map(|i| i.span())) } + AlterTableOperation::GenericOptions { options } => { + union_spans(options.iter().map(|option| option.span())) + } } } } @@ -3377,6 +3395,23 @@ SETTINGS update_parallel_mode = 'auto'"#; assert_eq!(test.get_source(options[0].value.span), "'public'"); } + #[test] + fn test_alter_foreign_table_option_spans() { + let dialect = &crate::dialect::PostgreSqlDialect {}; + let sql = "ALTER FOREIGN TABLE ft OPTIONS (ADD a '1', DROP b), ALTER COLUMN c OPTIONS (SET d '2')"; + let mut test = SpanTest::new(dialect, sql); + + let operations = match test.0.parse_statement().unwrap() { + Statement::AlterTable(alter) => alter.operations, + stmt => panic!("expected ALTER FOREIGN TABLE, got {stmt:?}"), + }; + assert_eq!(test.get_source(operations[0].span()), "a '1', DROP b"); + assert_eq!( + test.get_source(operations[1].span()), + "c OPTIONS (SET d '2'" + ); + } + #[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 f86406fb26..f2755c715a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -11592,11 +11592,15 @@ impl<'a> Parser<'a> { generated_as, sequence_options, } + } else if self.parse_keyword(Keyword::OPTIONS) { + AlterColumnOperation::GenericOptions { + options: self.parse_alter_generic_options()?, + } } else { let message = if is_postgresql { - "SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, or ADD GENERATED after ALTER COLUMN" + "SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, ADD GENERATED, or OPTIONS after ALTER COLUMN" } else { - "SET/DROP NOT NULL, SET DEFAULT, or SET DATA TYPE after ALTER COLUMN" + "SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, or OPTIONS after ALTER COLUMN" }; return self.expected_ref(message, self.peek_token_ref()); @@ -11731,6 +11735,10 @@ impl<'a> Parser<'a> { AlterTableOperation::SetLogged } else if self.parse_keywords(&[Keyword::SET, Keyword::UNLOGGED]) { AlterTableOperation::SetUnlogged + } else if self.parse_keyword(Keyword::OPTIONS) { + AlterTableOperation::GenericOptions { + options: self.parse_alter_generic_options()?, + } } else { let mut options = self.parse_options_with_keywords(&[Keyword::SET, Keyword::TBLPROPERTIES])?; @@ -11802,10 +11810,14 @@ impl<'a> Parser<'a> { Keyword::VIEW => self.parse_alter_view(), Keyword::TYPE => self.parse_alter_type(), Keyword::COLLATION => self.parse_alter_collation().map(Into::into), - Keyword::TABLE => self.parse_alter_table(false), + Keyword::TABLE => self.parse_alter_table(None), Keyword::ICEBERG => { self.expect_keyword(Keyword::TABLE)?; - self.parse_alter_table(true) + self.parse_alter_table(Some(AlterTableType::Iceberg)) + } + Keyword::FOREIGN => { + self.expect_keyword(Keyword::TABLE)?; + self.parse_alter_table(Some(AlterTableType::Foreign)) } Keyword::INDEX => { let index_name = self.parse_object_name(false)?; @@ -11847,7 +11859,7 @@ impl<'a> Parser<'a> { _ => { self.index = index; self.expected_ref( - "one of VIEW or TYPE or COLLATION or TABLE or INDEX or FUNCTION or AGGREGATE or ROLE or POLICY or TRIGGER or CONNECTOR or ICEBERG or SCHEMA or USER or OPERATOR", + "one of VIEW or TYPE or COLLATION or TABLE or INDEX or FUNCTION or AGGREGATE or ROLE or POLICY or TRIGGER or CONNECTOR or ICEBERG or FOREIGN TABLE or SCHEMA or USER or OPERATOR", self.peek_token_ref(), ) } @@ -12068,7 +12080,10 @@ impl<'a> Parser<'a> { } /// Parse a [Statement::AlterTable] - pub fn parse_alter_table(&mut self, iceberg: bool) -> Result { + pub fn parse_alter_table( + &mut self, + table_type: Option, + ) -> Result { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let only = self.parse_keyword(Keyword::ONLY); // [ ONLY ] let table_name = self.parse_object_name(false)?; @@ -12101,11 +12116,7 @@ impl<'a> Parser<'a> { operations, location, on_cluster, - table_type: if iceberg { - Some(AlterTableType::Iceberg) - } else { - None - }, + table_type, end_token: AttachedToken(end_token), } .into()) @@ -21342,14 +21353,36 @@ impl<'a> Parser<'a> { if !self.parse_keyword(Keyword::OPTIONS) { return Ok(None); } + self.expect_token(&Token::LParen)?; + let options = self.parse_comma_separated(Parser::parse_pg_option)?; + self.expect_token(&Token::RParen)?; + Ok(Some(options)) + } + + fn parse_pg_option(&mut self) -> Result { + let key = self.parse_identifier()?; + let value = self.parse_identifier()?; + Ok(CreateServerOption { key, value }) + } + + /// Parse the parenthesized list following `OPTIONS` in an `ALTER` statement. + fn parse_alter_generic_options(&mut self) -> Result, ParserError> { 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 }) + if p.parse_keyword(Keyword::SET) { + Ok(AlterGenericOption::Set(p.parse_pg_option()?)) + } else if p.parse_keyword(Keyword::DROP) { + Ok(AlterGenericOption::Drop(p.parse_identifier()?)) + } else { + let add_keyword = p.parse_keyword(Keyword::ADD); + Ok(AlterGenericOption::Add { + option: p.parse_pg_option()?, + add_keyword, + }) + } })?; self.expect_token(&Token::RParen)?; - Ok(Some(options)) + Ok(options) } /// Parse a `CREATE FOREIGN TABLE` statement. diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 379e37a660..6b3b6c8899 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15856,7 +15856,7 @@ fn parse_alter_trigger_spans() { #[test] fn parse_alter_rejects_missing_or_unknown_object_type() { - let objects = "one of VIEW or TYPE or COLLATION or TABLE or INDEX or FUNCTION or AGGREGATE or ROLE or POLICY or TRIGGER or CONNECTOR or ICEBERG or SCHEMA or USER or OPERATOR"; + let objects = "one of VIEW or TYPE or COLLATION or TABLE or INDEX or FUNCTION or AGGREGATE or ROLE or POLICY or TRIGGER or CONNECTOR or ICEBERG or FOREIGN TABLE or SCHEMA or USER or OPERATOR"; for (sql, found) in [ ("ALTER", "EOF"), ("ALTER 1", "1"), diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 7c5ac8712d..cfd05d2f43 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -11495,3 +11495,104 @@ fn display_create_function_begin_atomic_propagates_write_errors() { assert_eq!(write!(writer, "{stmt}"), Err(fmt::Error), "{rejected:?}"); } } + +#[test] +fn parse_alter_foreign_table() { + for sql in [ + "ALTER FOREIGN TABLE ft ADD COLUMN note TEXT", + "ALTER FOREIGN TABLE ft DROP COLUMN IF EXISTS note CASCADE", + "ALTER FOREIGN TABLE ft RENAME COLUMN note TO memo", + "ALTER FOREIGN TABLE ft ALTER COLUMN id TYPE BIGINT", + "ALTER FOREIGN TABLE ft ALTER COLUMN id SET DEFAULT 0", + "ALTER FOREIGN TABLE ft ALTER COLUMN id SET NOT NULL", + "ALTER FOREIGN TABLE ft ADD CONSTRAINT positive CHECK (id > 0) NOT VALID", + "ALTER FOREIGN TABLE ft VALIDATE CONSTRAINT positive", + "ALTER FOREIGN TABLE ft DROP CONSTRAINT positive", + "ALTER FOREIGN TABLE ft OWNER TO app", + "ALTER FOREIGN TABLE ft RENAME TO archive", + "ALTER FOREIGN TABLE IF EXISTS ONLY public.ft ADD COLUMN a INT, DROP COLUMN b", + ] { + let Statement::AlterTable(alter) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!(alter.table_type, Some(AlterTableType::Foreign)); + } + + let option = |key: &str, value: &str| CreateServerOption { + key: Ident::new(key), + value: Ident::with_quote('\'', value), + }; + let sql = "ALTER FOREIGN TABLE ft OPTIONS (ADD fetch_size '100', SET batch_size '10', DROP updatable, use_remote_estimate 'true')"; + let Statement::AlterTable(alter) = pg_and_generic().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + alter.operations, + vec![AlterTableOperation::GenericOptions { + options: vec![ + AlterGenericOption::Add { + option: option("fetch_size", "100"), + add_keyword: true, + }, + AlterGenericOption::Set(option("batch_size", "10")), + AlterGenericOption::Drop(Ident::new("updatable")), + AlterGenericOption::Add { + option: option("use_remote_estimate", "true"), + add_keyword: false, + }, + ], + }] + ); + + // As written by pg_dump. + let stmt = pg_and_generic().one_statement_parses_to( + "ALTER FOREIGN TABLE ONLY public.colopts ALTER COLUMN id OPTIONS (\n column_name 'remote_id'\n)", + "ALTER FOREIGN TABLE ONLY public.colopts ALTER COLUMN id OPTIONS (column_name 'remote_id')", + ); + let Statement::AlterTable(alter) = stmt else { + unreachable!() + }; + assert_eq!( + alter.operations, + vec![AlterTableOperation::AlterColumn { + column_name: Ident::new("id"), + op: AlterColumnOperation::GenericOptions { + options: vec![AlterGenericOption::Add { + option: option("column_name", "remote_id"), + add_keyword: false, + }], + }, + }] + ); + pg_and_generic().verified_stmt( + "ALTER FOREIGN TABLE ft ALTER COLUMN id OPTIONS (SET column_name 'remote_id', DROP other)", + ); + + for (sql, expected) in [ + ( + "ALTER FOREIGN TABLE ft OPTIONS (DROP fetch_size '100')", + "Expected: ), found: '100'", + ), + ( + "ALTER FOREIGN TABLE ft OPTIONS (SET fetch_size)", + "Expected: identifier, found: )", + ), + ( + "ALTER FOREIGN TABLE ft OPTIONS ()", + "Expected: identifier, found: )", + ), + ( + "ALTER FOREIGN ft OWNER TO app", + "Expected: TABLE, found: ft", + ), + ] { + assert_eq!( + pg_and_generic() + .parse_sql_statements(sql) + .unwrap_err() + .to_string(), + format!("sql parser error: {expected}"), + "{sql}" + ); + } +} From 6971f9595251c94f2721ff022af9694db8c12c4a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 09:54:17 +0100 Subject: [PATCH 2/4] Test error paths and formatter errors --- tests/sqlparser_postgres.rs | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index cfd05d2f43..5df5304a6d 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -11581,6 +11581,26 @@ fn parse_alter_foreign_table() { "ALTER FOREIGN TABLE ft OPTIONS ()", "Expected: identifier, found: )", ), + ( + "ALTER FOREIGN TABLE ft OPTIONS (ADD fetch_size)", + "Expected: identifier, found: )", + ), + ( + "ALTER FOREIGN TABLE ft OPTIONS (DROP)", + "Expected: identifier, found: )", + ), + ( + "ALTER FOREIGN TABLE ft OPTIONS (ADD fetch_size '100'", + "Expected: ), found: EOF", + ), + ( + "ALTER FOREIGN TABLE ft OPTIONS ADD fetch_size '100'", + "Expected: (, found: ADD", + ), + ( + "ALTER FOREIGN TABLE ft ALTER COLUMN id OPTIONS column_name 'remote_id'", + "Expected: (, found: column_name", + ), ( "ALTER FOREIGN ft OWNER TO app", "Expected: TABLE, found: ft", @@ -11595,4 +11615,42 @@ fn parse_alter_foreign_table() { "{sql}" ); } + + assert_eq!( + pg().parse_sql_statements("ALTER FOREIGN TABLE ft ALTER COLUMN id RESET") + .unwrap_err() + .to_string(), + "sql parser error: Expected: SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, ADD GENERATED, or OPTIONS after ALTER COLUMN, found: RESET" + ); + assert_eq!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("ALTER FOREIGN TABLE ft ALTER COLUMN id RESET") + .unwrap_err() + .to_string(), + "sql parser error: Expected: SET/DROP NOT NULL, SET DEFAULT, SET DATA TYPE, or OPTIONS after ALTER COLUMN, found: RESET" + ); +} + +#[test] +fn display_alter_foreign_table_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("ALTER FOREIGN TABLE ft OPTIONS (ADD fetch_size '100')"); + for rejected in ["ALTER FOREIGN TABLE ", "ADD "] { + let mut writer = RejectingWriter(rejected); + assert_eq!(write!(writer, "{stmt}"), Err(fmt::Error), "{rejected:?}"); + } } From b100c789bcd43511e994c16a76f1b83aad6a2ae6 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 09:59:50 +0100 Subject: [PATCH 3/4] Test CREATE FOREIGN TABLE OPTIONS errors --- tests/sqlparser_postgres.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 5df5304a6d..f3482752bf 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -11654,3 +11654,30 @@ fn display_alter_foreign_table_propagates_formatter_errors() { assert_eq!(write!(writer, "{stmt}"), Err(fmt::Error), "{rejected:?}"); } } + +#[test] +fn parse_create_foreign_table_options_errors() { + for (sql, expected) in [ + ( + "CREATE FOREIGN TABLE ft (a INT) SERVER s OPTIONS schema_name 'public'", + "Expected: (, found: schema_name", + ), + ( + "CREATE FOREIGN TABLE ft (a INT) SERVER s OPTIONS ()", + "Expected: identifier, found: )", + ), + ( + "CREATE FOREIGN TABLE ft (a INT) SERVER s OPTIONS (schema_name 'public'", + "Expected: ), found: EOF", + ), + ] { + assert_eq!( + pg_and_generic() + .parse_sql_statements(sql) + .unwrap_err() + .to_string(), + format!("sql parser error: {expected}"), + "{sql}" + ); + } +} From 9c72785db2e6e74e8251a4b90026eb04dd83be5c Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 11:18:59 +0100 Subject: [PATCH 4/4] Reject ON CLUSTER and LOCATION in ALTER FOREIGN TABLE --- src/parser/mod.rs | 15 +++++++++++++++ tests/sqlparser_postgres.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f2755c715a..351020c1b0 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12087,9 +12087,18 @@ impl<'a> Parser<'a> { let if_exists = self.parse_keywords(&[Keyword::IF, Keyword::EXISTS]); let only = self.parse_keyword(Keyword::ONLY); // [ ONLY ] let table_name = self.parse_object_name(false)?; + let on_cluster_loc = self.peek_token_ref().span.start; let on_cluster = self.parse_optional_on_cluster()?; + let foreign = table_type == Some(AlterTableType::Foreign); + if foreign && on_cluster.is_some() { + return parser_err!( + "Cannot specify ON CLUSTER in ALTER FOREIGN TABLE", + on_cluster_loc + ); + } let operations = self.parse_comma_separated(Parser::parse_alter_table_operation)?; + let location_loc = self.peek_token_ref().span.start; let mut location = None; if self.parse_keyword(Keyword::LOCATION) { location = Some(HiveSetLocation { @@ -12102,6 +12111,12 @@ impl<'a> Parser<'a> { location: self.parse_identifier()?, }); } + if foreign && location.is_some() { + return parser_err!( + "Cannot specify LOCATION in ALTER FOREIGN TABLE", + location_loc + ); + } let end_token = if self.peek_token_ref().token == Token::SemiColon { self.peek_token_ref().clone() diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index f3482752bf..0a44e18688 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -11681,3 +11681,30 @@ fn parse_create_foreign_table_options_errors() { ); } } + +#[test] +fn parse_alter_foreign_table_rejects_clauses() { + for (sql, expected) in [ + ( + "ALTER FOREIGN TABLE ft ON CLUSTER c ADD COLUMN x INT", + "Cannot specify ON CLUSTER in ALTER FOREIGN TABLE", + ), + ( + "ALTER FOREIGN TABLE ft ADD COLUMN x INT LOCATION 's3://x'", + "Cannot specify LOCATION in ALTER FOREIGN TABLE", + ), + ( + "ALTER FOREIGN TABLE ft ADD COLUMN x INT SET LOCATION 's3://x'", + "Cannot specify LOCATION in ALTER FOREIGN TABLE", + ), + ] { + assert_eq!( + pg_and_generic() + .parse_sql_statements(sql) + .unwrap_err() + .to_string(), + format!("sql parser error: {expected}"), + "{sql}" + ); + } +}