Preserve the opening token of `CREATE TABLE`
diff --git a/fuzz/fuzz_seeds/generic_1548_create_table_span.sql b/fuzz/fuzz_seeds/generic_1548_create_table_span.sql new file mode 100644 index 0000000..d8a588b --- /dev/null +++ b/fuzz/fuzz_seeds/generic_1548_create_table_span.sql
@@ -0,0 +1 @@ +CREATE TABLE t (id INT)
diff --git a/src/ast/ddl.rs b/src/ast/ddl.rs index 5d5b964..1035123 100644 --- a/src/ast/ddl.rs +++ b/src/ast/ddl.rs
@@ -2959,6 +2959,8 @@ #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct CreateTable { + /// The opening `CREATE` token, empty for tables constructed without one. + pub create_token: AttachedToken, /// `OR REPLACE` clause pub or_replace: bool, /// `TEMP` or `TEMPORARY` clause
diff --git a/src/ast/helpers/stmt_create_table.rs b/src/ast/helpers/stmt_create_table.rs index 4ab9ac2..2cc13b5 100644 --- a/src/ast/helpers/stmt_create_table.rs +++ b/src/ast/helpers/stmt_create_table.rs
@@ -24,6 +24,7 @@ #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::helpers::attached_token::AttachedToken; use crate::ast::{ ClusteredBy, ColumnDef, CommentDef, CreateTable, CreateTableLikeKind, CreateTableOptions, DistStyle, Expr, FileFormat, ForValues, HiveDistributionStyle, HiveFormat, Ident, @@ -65,6 +66,8 @@ #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct CreateTableBuilder { + /// The opening `CREATE` token, empty for tables constructed without one. + pub create_token: AttachedToken, /// Whether the statement uses `OR REPLACE`. pub or_replace: bool, /// Whether the table is `TEMPORARY`. @@ -198,6 +201,7 @@ /// Create a new `CreateTableBuilder` for the given table name. pub fn new(name: ObjectName) -> Self { Self { + create_token: AttachedToken::empty(), or_replace: false, temporary: false, unlogged: false, @@ -263,6 +267,11 @@ with_data: None, } } + /// Set the opening `CREATE` token. + pub fn create_token(mut self, create_token: AttachedToken) -> Self { + self.create_token = create_token; + self + } /// Set `OR REPLACE` for the CREATE TABLE statement. pub fn or_replace(mut self, or_replace: bool) -> Self { self.or_replace = or_replace; @@ -592,6 +601,7 @@ /// Consume the builder and produce a `CreateTable`. pub fn build(self) -> CreateTable { CreateTable { + create_token: self.create_token, or_replace: self.or_replace, temporary: self.temporary, unlogged: self.unlogged, @@ -677,6 +687,7 @@ impl From<CreateTable> for CreateTableBuilder { fn from(table: CreateTable) -> Self { Self { + create_token: table.create_token, or_replace: table.or_replace, temporary: table.temporary, unlogged: table.unlogged,
diff --git a/src/ast/spans.rs b/src/ast/spans.rs index eab9f71..894917d 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs
@@ -551,6 +551,7 @@ impl Spanned for CreateTable { fn span(&self) -> Span { let CreateTable { + create_token, or_replace: _, // bool temporary: _, // bool unlogged: _, // bool @@ -617,7 +618,8 @@ } = self; union_spans( - core::iter::once(name.span()) + core::iter::once(create_token.0.span) + .chain(core::iter::once(name.span())) .chain(core::iter::once(table_options.span())) .chain(columns.iter().map(|i| i.span())) .chain(constraints.iter().map(|i| i.span()))
diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index dc462f5..bed3c9b 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs
@@ -313,6 +313,7 @@ } if parser.parse_keyword(Keyword::CREATE) { + let create_token_index = parser.get_current_index(); // possibly CREATE STAGE //[ OR REPLACE ] let or_replace = parser.parse_keywords(&[Keyword::OR, Keyword::REPLACE]); @@ -353,7 +354,10 @@ or_replace, global, temporary, volatile, transient, iceberg, dynamic, parser, ) - .map(Into::into), + .map(|mut table| { + table.create_token = parser.token_at(create_token_index).clone().into(); + table.into() + }), ); } else if parser.parse_keyword(Keyword::DATABASE) { return Some(parse_create_database(or_replace, transient, parser)); @@ -2031,3 +2035,22 @@ Ok((when_clauses, else_clause)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::Spanned; + use crate::tokenizer::{Span, Token}; + + #[test] + fn create_table_span_snowflake_direct_entry() { + let mut parser = Parser::new(&SnowflakeDialect) + .try_with_sql("t (id INT); SELECT 1") + .unwrap(); + let table = parse_create_table(false, None, false, false, false, false, false, &mut parser) + .unwrap(); + assert_eq!(table.create_token.0.span, Span::empty()); + assert_eq!(table.span().start, table.name.span().start); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon); + } +}
diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 95ff188..8805119 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs
@@ -5352,6 +5352,9 @@ /// Parse a SQL CREATE statement pub fn parse_create(&mut self) -> Result<Statement, ParserError> { + let create_token_index = self.index.checked_sub(1).filter(|&index| { + matches!(&self.token_at(index).token, Token::Word(word) if word.keyword == Keyword::CREATE) + }); 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]); @@ -5377,7 +5380,7 @@ let persistent = dialect_of!(self is DuckDbDialect) && self.parse_one_of_keywords(&[Keyword::PERSISTENT]).is_some(); let create_view_params = self.parse_create_view_params()?; - if self.peek_keywords(&[Keyword::SNAPSHOT, Keyword::TABLE]) { + let mut statement = if self.peek_keywords(&[Keyword::SNAPSHOT, Keyword::TABLE]) { self.parse_create_snapshot_table().map(Into::into) } else if self.peek_keywords(&[Keyword::TEXT, Keyword::SEARCH]) { self.parse_create_text_search().map(Into::into) @@ -5478,7 +5481,13 @@ self.parse_create_foreign_table().map(Into::into) } else { self.expected_ref("an object type after CREATE", self.peek_token_ref()) + }?; + if let Statement::CreateTable(table) = &mut statement { + if let Some(index) = create_token_index { + table.create_token = self.token_at(index).clone().into(); + } } + Ok(statement) } fn parse_text_search_object_type(&mut self) -> Result<TextSearchObjectType, ParserError> {
diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index daebe63..66c5b5f 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs
@@ -3001,3 +3001,27 @@ err ); } + +#[test] +fn create_table_span_snapshot() { + use sqlparser::ast::Spanned; + use sqlparser::parser::Parser; + use sqlparser::tokenizer::Location; + + for canonical in [ + "CREATE SNAPSHOT TABLE t CLONE source", + "CREATE SNAPSHOT TABLE IF NOT EXISTS t CLONE source", + "CREATE OR REPLACE EXTERNAL TABLE t (id INT)", + ] { + bigquery_and_generic().verified_stmt(canonical); + bigquery_and_generic().one_of_identical_results(|dialect| { + let sql = format!("SELECT 1;\n {canonical}"); + let statements = Parser::parse_sql(dialect, &sql).unwrap(); + let Statement::CreateTable(table) = &statements[1] else { + panic!("Expected CREATE TABLE"); + }; + assert_eq!(table.span().start, Location::new(2, 3)); + assert_eq!(statements[1].span().start, Location::new(2, 3)); + }); + } +}
diff --git a/tests/sqlparser_comments.rs b/tests/sqlparser_comments.rs index ed78a77..e985346 100644 --- a/tests/sqlparser_comments.rs +++ b/tests/sqlparser_comments.rs
@@ -180,3 +180,24 @@ }] ); } + +#[test] +fn create_table_span_find_before() { + use sqlparser::ast::{Spanned, Statement}; + + let sql = r#"-- users docs +CREATE TABLE IF NOT EXISTS users (id INT)"#; + all_dialects().one_of_identical_results(|dialect| { + let (statements, comments) = Parser::parse_sql_with_comments(dialect, sql).unwrap(); + let Statement::CreateTable(table) = &statements[0] else { + panic!("Expected CREATE TABLE"); + }; + assert_eq!( + comments + .find_before(table.span().start) + .map(|comment| comment.as_str()) + .collect::<Vec<_>>(), + vec![" users docs"] + ); + }); +}
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 5a5c2b3..41eaf59 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs
@@ -20856,3 +20856,134 @@ "SELECT count(*) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t", ); } + +#[test] +fn create_table_span_starts_at_create() { + for (sql, expected_start) in [ + ("CREATE TABLE t (id INT)", Location::new(1, 1)), + ( + "CREATE TABLE IF NOT EXISTS users (id INT)", + Location::new(1, 1), + ), + ( + r#"-- table docs +CREATE TABLE + users (id INT)"#, + Location::new(2, 1), + ), + (" CREATE TEMPORARY TABLE t (id INT)", Location::new(1, 3)), + ] { + all_dialects().one_of_identical_results(|dialect| { + let statements = Parser::parse_sql(dialect, sql).unwrap(); + let Statement::CreateTable(table) = &statements[0] else { + panic!("Expected CREATE TABLE"); + }; + assert_eq!(statements[0].span().start, expected_start, "{sql}"); + assert_eq!(table.span().start, expected_start, "{sql}"); + }); + } +} + +#[test] +fn create_table_span_prefixes() { + for prefix in [ + "CREATE", + "CREATE OR REPLACE", + "CREATE TEMPORARY", + "CREATE OR REPLACE TEMPORARY", + "CREATE LOCAL TEMPORARY", + "CREATE GLOBAL TEMPORARY", + "CREATE TRANSIENT", + "CREATE VOLATILE", + "CREATE UNLOGGED", + "CREATE SET", + "CREATE MULTISET", + "CREATE MULTISET VOLATILE", + "CREATE EXTERNAL", + ] { + let canonical = format!("{prefix} TABLE IF NOT EXISTS t (id INT)"); + all_dialects().verified_stmt(&canonical); + all_dialects().one_of_identical_results(|dialect| { + let sql = format!("SELECT 1;\n {canonical}"); + let statements = Parser::parse_sql(dialect, &sql).unwrap(); + assert_eq!(statements[1].span().start, Location::new(2, 3), "{sql}"); + }); + } +} + +#[test] +fn create_table_span_semantic_identity() { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + fn hash(value: &impl Hash) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() + } + + all_dialects().one_of_identical_results(|dialect| { + let sql = "CREATE TABLE t AS SELECT 1"; + let original = Parser::parse_sql(dialect, sql).unwrap().remove(0); + let shifted = Parser::parse_sql(dialect, &format!("\n {sql}")) + .unwrap() + .remove(0); + assert_eq!(original, shifted); + assert_eq!(original.cmp(&shifted), core::cmp::Ordering::Equal); + assert_eq!(hash(&original), hash(&shifted)); + let (Statement::CreateTable(original), Statement::CreateTable(shifted)) = + (&original, &shifted) + else { + panic!("Expected CREATE TABLE"); + }; + assert_ne!(original.span().start, shifted.span().start); + assert_eq!(original, shifted); + assert_eq!(original.cmp(shifted), core::cmp::Ordering::Equal); + assert_eq!(hash(original), hash(shifted)); + assert_eq!( + original.span().end, + original.query.as_ref().unwrap().span().end + ); + assert_eq!( + shifted.span().end, + shifted.query.as_ref().unwrap().span().end + ); + }); +} + +#[test] +fn create_table_span_direct_entries() { + for (sql, consume_create, expected_start) in [ + ( + " CREATE TABLE t (id INT); SELECT 1", + true, + Location::new(1, 3), + ), + ("TABLE t (id INT); SELECT 1", false, Location::new(1, 7)), + ] { + let mut parser = Parser::new(&GenericDialect).try_with_sql(sql).unwrap(); + if consume_create { + parser.expect_keyword_is(Keyword::CREATE).unwrap(); + } + let statement = parser.parse_create().unwrap(); + assert_eq!(statement.span().start, expected_start); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon); + } + + for (entry, sql) in [ + (0, "t (id INT); SELECT 1"), + (1, "TABLE t (id INT); SELECT 1"), + (2, "SNAPSHOT TABLE t CLONE source; SELECT 1"), + ] { + let mut parser = Parser::new(&GenericDialect).try_with_sql(sql).unwrap(); + let table = match entry { + 0 => parser.parse_create_table(false, false, false, None, false, false, None), + 1 => parser.parse_create_external_table(false), + _ => parser.parse_create_snapshot_table(), + } + .unwrap(); + assert_eq!(table.create_token.0.span, Span::empty()); + assert_eq!(table.span().start, table.name.span().start); + assert_eq!(parser.peek_token_ref().token, Token::SemiColon); + } +}
diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index 216d514..bfeae95 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs
@@ -700,6 +700,7 @@ let stmt = duckdb_and_generic().verified_stmt(sql); assert_eq!( Statement::CreateTable(CreateTable { + create_token: AttachedToken::empty(), or_replace: Default::default(), temporary: Default::default(), unlogged: Default::default(),
diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index e07d870..37d98d2 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs
@@ -1935,6 +1935,7 @@ assert_eq!( ms_and_generic().verified_stmt(sql), Statement::CreateTable(CreateTable { + create_token: AttachedToken::empty(), or_replace: false, temporary: false, unlogged: false, @@ -2132,6 +2133,7 @@ assert_eq!( ms_and_generic().verified_stmt(sql), Statement::CreateTable(CreateTable { + create_token: AttachedToken::empty(), or_replace: false, temporary: false, unlogged: false,
diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 1e00288..5556f23 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs
@@ -7020,6 +7020,7 @@ assert_eq!( create_table, CreateTable { + create_token: AttachedToken::empty(), or_replace: false, temporary: false, unlogged: false,
diff --git a/tests/sqlparser_snowflake.rs b/tests/sqlparser_snowflake.rs index 6c8c73c..2726f9e 100644 --- a/tests/sqlparser_snowflake.rs +++ b/tests/sqlparser_snowflake.rs
@@ -23,7 +23,7 @@ use sqlparser::ast::helpers::stmt_data_loading::{StageLoadSelectItem, StageLoadSelectItemKind}; use sqlparser::ast::*; use sqlparser::dialect::{Dialect, GenericDialect, SnowflakeDialect}; -use sqlparser::parser::{ParserError, ParserOptions}; +use sqlparser::parser::{Parser, ParserError, ParserOptions}; use sqlparser::tokenizer::*; use test_utils::*; @@ -5101,3 +5101,26 @@ ParserError::ParserError("Expected: stage name identifier, found: .".to_string()), ); } + +#[test] +fn create_table_span_snowflake_prefixes() { + use sqlparser::ast::Spanned; + use sqlparser::tokenizer::Location; + + for canonical in [ + "CREATE OR REPLACE TRANSIENT TABLE t (id INT)", + "CREATE ICEBERG TABLE t (id INT) BASE_LOCATION='t'", + "CREATE DYNAMIC TABLE t TARGET_LAG='DOWNSTREAM' WAREHOUSE=wh AS SELECT 1", + "CREATE DYNAMIC ICEBERG TABLE t BASE_LOCATION='t' TARGET_LAG='DOWNSTREAM' WAREHOUSE=wh AS SELECT 1", + "CREATE GLOBAL TEMPORARY TABLE IF NOT EXISTS t (id INT)", + ] { + snowflake().verified_stmt(canonical); + let sql = format!("SELECT 1;\n {canonical}"); + let statements = Parser::parse_sql(&SnowflakeDialect, &sql).unwrap(); + let Statement::CreateTable(table) = &statements[1] else { + panic!("Expected CREATE TABLE"); + }; + assert_eq!(table.span().start, Location::new(2, 3)); + assert_eq!(statements[1].span().start, Location::new(2, 3)); + } +}