diff --git a/crates/squawk_ide/src/code_actions/mod.rs b/crates/squawk_ide/src/code_actions/mod.rs index 9e409f92..ed956aba 100644 --- a/crates/squawk_ide/src/code_actions/mod.rs +++ b/crates/squawk_ide/src/code_actions/mod.rs @@ -13,12 +13,14 @@ mod rewrite_as_dollar_quoted_string; mod rewrite_as_regular_string; mod rewrite_between_as_binary_expression; mod rewrite_cast_to_double_colon; +mod rewrite_create_table_as_as_select_into; mod rewrite_double_colon_to_cast; mod rewrite_from; mod rewrite_leading_from; mod rewrite_not_equals_operator; mod rewrite_select_as_table; mod rewrite_select_as_values; +mod rewrite_select_into_as_create_table_as; mod rewrite_table_as_select; mod rewrite_timestamp_type; mod rewrite_values_as_select; @@ -36,12 +38,14 @@ use rewrite_as_dollar_quoted_string::rewrite_as_dollar_quoted_string; use rewrite_as_regular_string::rewrite_as_regular_string; use rewrite_between_as_binary_expression::rewrite_between_as_binary_expression; use rewrite_cast_to_double_colon::rewrite_cast_to_double_colon; +use rewrite_create_table_as_as_select_into::rewrite_create_table_as_as_select_into; use rewrite_double_colon_to_cast::rewrite_double_colon_to_cast; use rewrite_from::rewrite_from; use rewrite_leading_from::rewrite_leading_from; use rewrite_not_equals_operator::rewrite_not_equals_operator; use rewrite_select_as_table::rewrite_select_as_table; use rewrite_select_as_values::rewrite_select_as_values; +use rewrite_select_into_as_create_table_as::rewrite_select_into_as_create_table_as; use rewrite_table_as_select::rewrite_table_as_select; use rewrite_timestamp_type::rewrite_timestamp_type; use rewrite_values_as_select::rewrite_values_as_select; @@ -72,6 +76,8 @@ pub fn code_actions(db: &dyn Db, file: File, offset: TextSize) -> Option, + offset: TextSize, +) -> Option<()> { + let token = token_from_offset(db, file, offset)?; + let create_table_as = token + .parent_ancestors() + .find_map(ast::CreateTableAs::cast)?; + + if create_table_as.if_not_exists().is_some() + || create_table_as.using_method().is_some() + || create_table_as.on_commit().is_some() + || create_table_as.tablespace().is_some() + || create_table_as.with_data().is_some() + || create_table_as.with_no_data().is_some() + || create_table_as.with_params().is_some() + || create_table_as.without_oids().is_some() + { + return None; + } + + // TODO: support more + let ast::SelectVariant::Select(select) = create_table_as.query()? else { + return None; + }; + + let delete_edit = { + let as_token = create_table_as.as_token()?; + let mut delete_end = as_token.text_range().end(); + if let Some(next) = as_token.next_sibling_or_token() + && next.kind() == SyntaxKind::WHITESPACE + { + delete_end = next.text_range().end(); + } + Edit::delete(TextRange::new( + create_table_as.syntax().text_range().start(), + delete_end, + )) + }; + + let insert_edit = { + let persistence_text = create_table_as + .persistence() + .map(|p| format!(" {}", p.syntax())) + .unwrap_or_default(); + let into_text = format!( + " into{} {}", + persistence_text, + create_table_as.path()?.syntax() + ); + let select_end = select.select_clause()?.syntax().text_range().end(); + Edit::insert(into_text, select_end) + }; + + actions.push(CodeAction { + title: "Rewrite as `select into`".to_owned(), + edits: vec![delete_edit, insert_edit], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_create_table_as_as_select_into; + + #[test] + fn simple() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create table$0 mytable as select a, b from foo;" + ), + @"select a, b into mytable from foo;" + ); + } + + #[test] + fn with_temp() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create temp table$0 mytable as select a, b from foo;" + ), + @"select a, b into temp mytable from foo;" + ); + } + + #[test] + fn with_temporary() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create temporary table$0 mytable as select a from foo;" + ), + @"select a into temporary mytable from foo;" + ); + } + + #[test] + fn qualified_name() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create table$0 myschema.mytable as select a from foo;" + ), + @"select a into myschema.mytable from foo;" + ); + } + + #[test] + fn with_where() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create table mytable$0 as select a, b from foo where a > 1;" + ), + @"select a, b into mytable from foo where a > 1;" + ); + } + + #[test] + fn with_cte() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "create table mytable as$0 with cte as (select 1) select a from cte;" + ), + @"with cte as (select 1) select a into mytable from cte;" + ); + } + + #[test] + fn on_create_keyword() { + assert_snapshot!( + apply_code_action( + rewrite_create_table_as_as_select_into, + "creat$0e table mytable as select a from foo;" + ), + @"select a into mytable from foo;" + ); + } + + #[test] + fn not_applicable_with_if_not_exists() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "create table if not exists$0 mytable as select a from foo;" + )); + } + + #[test] + fn not_applicable_with_compound_select() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "create table mytable as select a from foo$0 union all select b from bar;" + )); + } + + #[test] + fn not_applicable_with_values() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "create table mytable$0 as values (1, 2);" + )); + } + + #[test] + fn not_applicable_with_paren_select() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "create table mytable$0 as (select 1 a);" + )); + } + + #[test] + fn not_applicable_with_table() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "create table mytable$0 as table u;" + )); + } + + #[test] + fn not_applicable_on_select_into() { + assert!(code_action_not_applicable( + rewrite_create_table_as_as_select_into, + "select a int$0o mytable from foo;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_select_into_as_create_table_as.rs b/crates/squawk_ide/src/code_actions/rewrite_select_into_as_create_table_as.rs new file mode 100644 index 00000000..ae77a3ef --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_select_into_as_create_table_as.rs @@ -0,0 +1,213 @@ +use rowan::{TextRange, TextSize}; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + SyntaxKind, + ast::{self, AstNode}, +}; + +use crate::{db::File, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_select_into_as_create_table_as( + db: &dyn Db, + file: File, + actions: &mut Vec, + offset: TextSize, +) -> Option<()> { + let token = token_from_offset(db, file, offset)?; + let select_into = token.parent_ancestors().find_map(ast::SelectInto::cast)?; + + let into_clause = select_into.into_clause()?; + + // temp, unlogged, etc. + let persistence = into_clause + .persistence() + .map(|x| format!("{} ", x.syntax())) + .unwrap_or_default(); + let create_prefix = format!( + "create {}table {} as ", + persistence, + into_clause.path()?.syntax() + ); + let stmt_start = { + let mut start = select_into.syntax().text_range().start(); + for parent in select_into.syntax().parent()?.ancestors() { + if ast::CompoundSelect::can_cast(parent.kind()) + || ast::ParenSelect::can_cast(parent.kind()) + { + start = parent.text_range().start(); + } else { + break; + } + } + start + }; + let insert_prefix = Edit::insert(create_prefix, stmt_start); + + let delete_into = { + let range = into_clause.syntax().text_range(); + let mut start = range.start(); + if let Some(prev) = into_clause.syntax().prev_sibling_or_token() + && prev.kind() == SyntaxKind::WHITESPACE + { + start = prev.text_range().start(); + } + Edit::delete(TextRange::new(start, range.end())) + }; + + actions.push(CodeAction { + title: "Rewrite as `create table as`".to_owned(), + edits: vec![delete_into, insert_prefix], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_select_into_as_create_table_as; + + #[test] + fn simple() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select$0 a, b into mytable from foo;" + ), + @"create table mytable as select a, b from foo;" + ); + } + + #[test] + fn with_temp() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a, b int$0o temp mytable from foo;" + ), + @"create temp table mytable as select a, b from foo;" + ); + } + + #[test] + fn with_temporary() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a int$0o temporary mytable from foo;" + ), + @"create temporary table mytable as select a from foo;" + ); + } + + #[test] + fn with_table_keyword() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a into$0 table mytable from foo;" + ), + @"create table mytable as select a from foo;" + ); + } + + #[test] + fn qualified_name() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a into$0 myschema.mytable from foo;" + ), + @"create table myschema.mytable as select a from foo;" + ); + } + + #[test] + fn with_where() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a, b into$0 mytable from foo where a > 1;" + ), + @"create table mytable as select a, b from foo where a > 1;" + ); + } + + #[test] + fn with_cte() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "with cte as (select 1) select a int$0o mytable from cte;" + ), + @"create table mytable as with cte as (select 1) select a from cte;" + ); + } + + #[test] + fn on_into_keyword() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select a, b int$0o mytable from foo;" + ), + @"create table mytable as select a, b from foo;" + ); + } + + #[test] + fn rewrite_select_into_parent_as_create_table_as() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "((select 1 b int$0o t))" + ), + @"create table t as ((select 1 b))" + ); + } + + #[test] + fn compound_paren() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "(select 1 a int$0o t union select 2 a)" + ), + @"create table t as (select 1 a union select 2 a)" + ); + } + + #[test] + fn compound_no_paren() { + assert_snapshot!( + apply_code_action( + rewrite_select_into_as_create_table_as, + "select 1 a int$0o t union select 2 a" + ), + @"create table t as select 1 a union select 2 a" + ); + } + + #[test] + fn not_applicable_on_select() { + assert!(code_action_not_applicable( + rewrite_select_into_as_create_table_as, + "sel$0ect a from foo;" + )); + } + + #[test] + fn not_applicable_on_create_table_as() { + assert!(code_action_not_applicable( + rewrite_select_into_as_create_table_as, + "create table$0 mytable as select a from foo;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/test_utils.rs b/crates/squawk_ide/src/code_actions/test_utils.rs index ca492f38..e9bf5386 100644 --- a/crates/squawk_ide/src/code_actions/test_utils.rs +++ b/crates/squawk_ide/src/code_actions/test_utils.rs @@ -62,7 +62,7 @@ pub(super) fn apply_code_action( assert_eq!( reparse.errors(), vec![], - "Code actions shouldn't cause syntax errors" + "Code actions shouldn't cause syntax errors.\nParsed:\n{result}" ); } } diff --git a/crates/squawk_linter/src/ignore.rs b/crates/squawk_linter/src/ignore.rs index f793ed21..9b99f211 100644 --- a/crates/squawk_linter/src/ignore.rs +++ b/crates/squawk_linter/src/ignore.rs @@ -41,6 +41,20 @@ pub(crate) fn comment_body(token: &SyntaxToken) -> Option<(&str, TextRange)> { None } +/// ```sql +/// squawk-ignore ban-drop-column -- we don't need to worry about this +/// ``` +/// becomes +/// ```sql +/// squawk-ignore ban-drop-column +/// ``` +pub(crate) fn trim_trailing_comment(text: &str) -> &str { + let trimmed = text.trim(); + trimmed + .find("--") + .map_or(trimmed, |idx| trimmed[..idx].trim_end()) +} + // TODO: maybe in a future version we can rename this to squawk-ignore-line pub const IGNORE_LINE_TEXT: &str = "squawk-ignore"; pub const IGNORE_FILE_TEXT: &str = "squawk-ignore-file"; @@ -50,10 +64,7 @@ pub fn ignore_rule_info(token: &SyntaxToken) -> Option<(&str, TextRange, IgnoreK let without_start = comment_body.trim_start(); let trim_start_size = comment_body.len() - without_start.len(); - let without_end = without_start - .find("--") - .map_or(without_start, |idx| &without_start[..idx]) - .trim_end(); + let without_end = trim_trailing_comment(without_start); let trim_end_size = without_start.len() - without_end.len(); for (prefix, kind) in [ @@ -137,11 +148,7 @@ pub fn has_disable_assume_in_transaction(file: &SyntaxNode) -> bool { if token.kind() == SyntaxKind::COMMENT => { if let Some((body, _range)) = comment_body(&token) { - let trimmed = body.trim(); - let trimmed = trimmed - .find("--") - .map_or(trimmed, |idx| trimmed[..idx].trim_end()); - if trimmed == DISABLE_ASSUME_IN_TRANSACTION { + if trim_trailing_comment(body) == DISABLE_ASSUME_IN_TRANSACTION { return true; } } diff --git a/crates/squawk_parser/src/grammar.rs b/crates/squawk_parser/src/grammar.rs index e7984fa8..2abdf1e8 100644 --- a/crates/squawk_parser/src/grammar.rs +++ b/crates/squawk_parser/src/grammar.rs @@ -2757,7 +2757,7 @@ fn select(p: &mut Parser, m: Option, r: &SelectRestrictions) -> Option