diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 66f2cad3e..44dd1b9c6 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,75 @@ 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'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, + /// 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)) + .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 20058b83a..bd0cfd507 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,7 @@ impl fmt::Display for CreateServerStatement { } } -/// A key/value option for `CREATE SERVER`. +/// 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/ast/spans.rs b/src/ast/spans.rs index 7acbd7d0b..250b0414b 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) => { @@ -3163,4 +3164,19 @@ WHERE id = 1 Span::new(Location::new(2, 8), Location::new(4, 52)) ); } + + #[test] + 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); + + // 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 15f135fff..93c86b75f 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(); @@ -5359,6 +5360,25 @@ 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 + || global.is_some() + || transient + || volatile + || multiset.is_some() + || persistent + || create_view_params.is_some() + { + return parser_err!( + "CREATE FOREIGN TABLE does not accept this modifier", + modifier_loc + ); + } + self.parse_create_foreign_table().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) } @@ -20431,16 +20451,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_pg_options_clause()?; Ok(Statement::CreateServer(CreateServerStatement { name, @@ -20452,6 +20463,51 @@ impl<'a> Parser<'a> { })) } + /// 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); + } + 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. + /// + /// 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_pg_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 d71e49b27..8e4d59cd1 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9639,6 +9639,102 @@ fn parse_lock_table() { } } +#[test] +fn parse_create_foreign_table() { + // 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')"; + 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_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(_)) + )); + + // 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_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 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!( + 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] +fn parse_create_foreign_table_with_check_constraint() { + // PostgreSQL accepts table-level CHECK constraints in CREATE FOREIGN TABLE. + 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