fix(sqlserver): support multi-column ALTER TABLE ADD

Closes #5484
This commit is contained in:
t8y2 2026-08-06 17:33:58 +08:00
parent cd6a3c8b5c
commit 6f1bf65726
No known key found for this signature in database
2 changed files with 153 additions and 4 deletions

View File

@ -127,15 +127,125 @@ fn parse_sqlserver(sql: &str) -> Result<Vec<Statement>, ParserError> {
Err(error) => {
let mut fallback_tokens = Tokenizer::new(&dialect, sql).tokenize_with_location()?;
normalize_sqlserver_create_proc_tokens(&mut fallback_tokens);
if remove_sqlserver_query_hint_tokens(&mut fallback_tokens) {
Parser::new(&dialect).with_tokens_with_locations(fallback_tokens).parse_statements()
} else {
Err(error)
let mut changed = normalize_sqlserver_alter_table_add_tokens(&mut fallback_tokens);
changed |= remove_sqlserver_query_hint_tokens(&mut fallback_tokens);
if changed {
if let Ok(statements) =
Parser::new(&dialect).with_tokens_with_locations(fallback_tokens).parse_statements()
{
return Ok(statements);
}
}
Err(error)
}
}
}
fn normalize_sqlserver_alter_table_add_tokens(tokens: &mut Vec<TokenWithSpan>) -> bool {
let mut insertions = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
if unquoted_token_keyword(&tokens[index]) != Some(Keyword::ALTER) {
index += 1;
continue;
}
let Some(table_index) = next_significant_token_index(tokens, index + 1) else {
break;
};
if unquoted_token_keyword(&tokens[table_index]) != Some(Keyword::TABLE) {
index += 1;
continue;
}
let statement_end = sqlserver_statement_end_index(tokens, table_index + 1);
let Some(add_index) = sqlserver_alter_table_add_index(tokens, table_index + 1, statement_end) else {
index = statement_end.saturating_add(1);
continue;
};
let add_token = tokens[add_index].clone();
let mut depth = 0usize;
for token_index in add_index + 1..statement_end {
match tokens[token_index].token {
Token::LParen => depth += 1,
Token::RParen => depth = depth.saturating_sub(1),
Token::Comma if depth == 0 => {
let Some(next_index) = next_significant_token_index(tokens, token_index + 1) else {
continue;
};
if next_index >= statement_end {
continue;
}
if is_sqlserver_alter_table_operation_starter(&tokens[next_index]) {
break;
}
insertions.push((next_index, add_token.clone()));
}
_ => {}
}
}
index = statement_end.saturating_add(1);
}
let changed = !insertions.is_empty();
for (index, token) in insertions.into_iter().rev() {
tokens.insert(index, token);
}
changed
}
fn sqlserver_statement_end_index(tokens: &[TokenWithSpan], start: usize) -> usize {
let mut depth = 0usize;
for (index, token) in tokens.iter().enumerate().skip(start) {
match token.token {
Token::LParen => depth += 1,
Token::RParen => depth = depth.saturating_sub(1),
Token::SemiColon | Token::EOF if depth == 0 => return index,
_ => {}
}
}
tokens.len()
}
fn sqlserver_alter_table_add_index(tokens: &[TokenWithSpan], start: usize, end: usize) -> Option<usize> {
let mut depth = 0usize;
for (index, token) in tokens.iter().enumerate().take(end).skip(start) {
match token.token {
Token::LParen => depth += 1,
Token::RParen => depth = depth.saturating_sub(1),
_ if depth == 0 && unquoted_token_keyword(token) == Some(Keyword::ADD) => return Some(index),
_ => {}
}
}
None
}
fn is_sqlserver_alter_table_operation_starter(token: &TokenWithSpan) -> bool {
let Token::Word(word) = &token.token else {
return false;
};
if word.quote_style.is_some() {
return false;
}
matches!(
word.value.to_ascii_uppercase().as_str(),
"ADD"
| "ALTER"
| "DISABLE"
| "DROP"
| "ENABLE"
| "NOCHECK"
| "PARTITION"
| "REBUILD"
| "RENAME"
| "REPLICA"
| "SET"
| "SWAP"
| "SWITCH"
)
}
fn remove_sqlserver_query_hint_tokens(tokens: &mut Vec<TokenWithSpan>) -> bool {
let mut depth = 0usize;
let mut ranges = Vec::new();
@ -316,6 +426,13 @@ fn token_keyword(token: &TokenWithSpan) -> Option<Keyword> {
}
}
fn unquoted_token_keyword(token: &TokenWithSpan) -> Option<Keyword> {
match &token.token {
Token::Word(word) if word.quote_style.is_none() => Some(word.keyword),
_ => None,
}
}
fn starts_with_duckdb_parser_gap_sql(sql: &str) -> bool {
starts_with_duckdb_result_sql_keyword(sql)
&& starts_with_executable_sql_keyword(sql, &["FROM", "SUMMARIZE", "SUMMARISE", "PIVOT", "UNPIVOT"])

View File

@ -410,6 +410,38 @@ fn sqlserver_proc_identifiers_remain_identifiers_outside_create() {
assert_eq!(analysis.columns[0].name, "proc");
}
#[test]
fn sqlserver_alter_table_single_add_supports_multiple_columns() {
for sql in [
"ALTER TABLE dbo.demo\nADD isOldWell BIT NULL,\n isNewWell BIT NULL;",
"ALTER TABLE [dbo].[demo] ADD amount DECIMAL(10, 2) DEFAULT (0), [display_name] NVARCHAR(50) NULL;",
"ALTER TABLE dbo.demo ADD enabled BIT NULL, CHECK (enabled IN (0, 1));",
] {
analyze_sql_references(sql, Some("sqlserver"))
.unwrap_or_else(|error| panic!("SQL Server single-ADD multi-column ALTER TABLE should analyze: {error}"));
}
}
#[test]
fn sqlserver_alter_table_add_normalization_preserves_boundaries() {
analyze_sql_references("ALTER TABLE dbo.demo ADD isOldWell BIT NULL, ADD isNewWell BIT NULL;", Some("sqlserver"))
.expect("existing repeated-ADD parser behavior should remain valid");
let missing_comma =
analyze_sql_references("ALTER TABLE dbo.demo ADD isOldWell BIT NULL isNewWell BIT NULL;", Some("sqlserver"))
.expect_err("missing column separators must remain invalid");
assert!(missing_comma.contains("isNewWell"));
analyze_sql_references(
"ALTER TABLE dbo.demo ADD amount DECIMAL(10, 2) NULL, label NVARCHAR(20) NULL; SELECT label FROM dbo.demo;",
Some("sqlserver"),
)
.expect("data-type commas and multiple statements should remain parseable");
analyze_sql_references("ALTER TABLE dbo.demo ADD first_flag BIT NULL, second_flag BIT NULL;", Some("postgres"))
.expect_err("other dialects must not inherit SQL Server ALTER TABLE normalization");
}
#[test]
fn sqlserver_query_hints_do_not_raise_parser_errors() {
let analysis = analyze_sql_references(