From 8567574f2eec5e97ba31716ca99b45e719c61026 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 28 Oct 2026 08:44:32 +0100 Subject: [PATCH] PostgreSQL: Support column OPTIONS in CREATE FOREIGN TABLE --- .../postgres_foreign_table_column_options.sql | 1 + src/ast/ddl.rs | 8 +++ src/ast/spans.rs | 5 ++ src/dialect/mod.rs | 7 +++ src/dialect/postgresql.rs | 5 ++ src/parser/mod.rs | 8 ++- tests/sqlparser_postgres.rs | 58 +++++++++++++++++++ 7 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 fuzz/fuzz_seeds/postgres_foreign_table_column_options.sql diff --git a/fuzz/fuzz_seeds/postgres_foreign_table_column_options.sql b/fuzz/fuzz_seeds/postgres_foreign_table_column_options.sql new file mode 100644 index 0000000000..c61c522ed3 --- /dev/null +++ b/fuzz/fuzz_seeds/postgres_foreign_table_column_options.sql @@ -0,0 +1 @@ +CREATE FOREIGN TABLE ft (id INT OPTIONS (column_name 'remote_id') NOT NULL) SERVER s \ No newline at end of file diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 59e38e3b15..0343b65a95 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs @@ -2052,6 +2052,11 @@ pub enum ColumnOption { /// [1]: https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#view_column_option_list /// [2]: https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#column_option_list Options(Vec), + /// PostgreSQL `OPTIONS ( option 'value' [, ...] )` of a column, passed to the + /// foreign-data wrapper of a foreign table. + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/sql-createforeigntable.html) + GenericOptions(Vec), /// Creates an identity or an autoincrement column in a table. /// Syntax /// ```sql @@ -2225,6 +2230,9 @@ impl fmt::Display for ColumnOption { Options(options) => { write!(f, "OPTIONS({})", display_comma_separated(options)) } + GenericOptions(options) => { + write!(f, "OPTIONS ({})", display_comma_separated(options)) + } Identity(parameters) => { write!(f, "{parameters}") } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d3aa28d032..f9e951b9bc 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -901,6 +901,11 @@ impl Spanned for ColumnOption { ColumnOption::OnUpdate(expr) => expr.span(), ColumnOption::Generated { .. } => Span::empty(), ColumnOption::Options(vec) => union_spans(vec.iter().map(|i| i.span())), + ColumnOption::GenericOptions(options) => union_spans( + options + .iter() + .map(|option| option.key.span.union(&option.value.span)), + ), ColumnOption::Identity(..) => Span::empty(), ColumnOption::OnConflict(..) => Span::empty(), ColumnOption::Policy(..) => Span::empty(), diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 8fc72eec34..dce6fa306b 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1624,6 +1624,13 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports `OPTIONS ( option 'value' [, ...] )` + /// in a column definition. + /// + /// + fn supports_column_generic_options(&self) -> bool { + false + } /// Returns true if the specified keyword is reserved and cannot be /// used as an identifier without special handling like quoting. fn is_reserved_for_identifier(&self, kw: Keyword) -> bool { diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index e5a1014bfa..35e82f8a87 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -618,4 +618,9 @@ impl Dialect for PostgreSqlDialect { fn supports_table_inheritance_markers(&self) -> bool { true } + + /// See + fn supports_column_generic_options(&self) -> bool { + true + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 11accc3996..3b13ed5a36 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10384,6 +10384,12 @@ impl<'a> Parser<'a> { Ok(Some(ColumnOption::OnUpdate(expr))) } else if self.parse_keyword(Keyword::GENERATED) { self.parse_optional_column_option_generated() + } else if self.dialect.supports_column_generic_options() + && self.peek_keyword(Keyword::OPTIONS) + { + Ok(self + .parse_pg_options_clause()? + .map(ColumnOption::GenericOptions)) } else if dialect_of!(self is BigQueryDialect | GenericDialect) && self.parse_keyword(Keyword::OPTIONS) { @@ -21544,7 +21550,7 @@ impl<'a> Parser<'a> { /// Parse a `CREATE FOREIGN TABLE` statement. /// - /// Per-column `OPTIONS ( ... )` and the `PARTITION OF` form are not parsed yet. + /// The `PARTITION OF` form is not parsed yet. /// /// See pub fn parse_create_foreign_table(&mut self) -> Result { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index be2c7e9563..389521e1ce 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -12291,3 +12291,61 @@ fn parse_create_table_partition_of_untyped_columns() { ); } } + +#[test] +fn parse_column_generic_options() { + let sql = r#"CREATE FOREIGN TABLE ft (id INT OPTIONS (column_name 'remote_id') NOT NULL, b TEXT OPTIONS (column_name 'rb', "x" 'y') COLLATE "C") SERVER s"#; + let Statement::CreateForeignTable(stmt) = pg().verified_stmt(sql) else { + unreachable!() + }; + assert_eq!( + stmt.columns[0].options, + vec![ + ColumnOptionDef { + name: None, + option: ColumnOption::GenericOptions(vec![CreateServerOption { + key: Ident::new("column_name"), + value: Ident::with_quote('\'', "remote_id"), + }]), + }, + ColumnOptionDef { + name: None, + option: ColumnOption::NotNull, + }, + ] + ); + assert_eq!( + stmt.columns[1].options[0].option, + ColumnOption::GenericOptions(vec![ + CreateServerOption { + key: Ident::new("column_name"), + value: Ident::with_quote('\'', "rb"), + }, + CreateServerOption { + key: Ident::with_quote('"', "x"), + value: Ident::with_quote('\'', "y"), + }, + ]) + ); + + // PostgreSQL rejects column options on a table that is not foreign only at execution. + pg().verified_stmt("CREATE TABLE t (id INT OPTIONS (column_name 'remote_id'))"); + pg().verified_stmt("ALTER TABLE t ADD COLUMN note TEXT OPTIONS (column_name 'memo')"); + + for (sql, expected) in [ + ( + "CREATE FOREIGN TABLE ft (id INT OPTIONS (column_name)) SERVER s", + "Expected: identifier, found: )", + ), + ( + "CREATE FOREIGN TABLE ft (id INT OPTIONS ()) SERVER s", + "Expected: identifier, found: )", + ), + ] { + assert_eq!( + pg().parse_sql_statements(sql).unwrap_err().to_string(), + format!("sql parser error: {expected}"), + "{sql}" + ); + } +}