Skip to content

Commit fe64453

Browse files
authored
ide: add code action for making inferred column alias explicit (#781)
1 parent fa252e3 commit fe64453

File tree

4 files changed

+266
-44
lines changed

4 files changed

+266
-44
lines changed

crates/squawk_ide/src/code_actions.rs

Lines changed: 146 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
use rowan::TextSize;
22
use squawk_linter::Edit;
33
use squawk_syntax::{
4-
SyntaxKind, SyntaxNode,
4+
SyntaxKind,
55
ast::{self, AstNode},
66
};
77

8-
use crate::{generated::keywords::RESERVED_KEYWORDS, offsets::token_from_offset};
8+
use crate::{
9+
column_name::ColumnName,
10+
offsets::token_from_offset,
11+
quote::{quote_column_alias, unquote_ident},
12+
};
913

1014
#[derive(Debug, Clone)]
1115
pub enum ActionKind {
@@ -29,6 +33,7 @@ pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option<Vec<CodeA
2933
rewrite_select_as_table(&mut actions, &file, offset);
3034
quote_identifier(&mut actions, &file, offset);
3135
unquote_identifier(&mut actions, &file, offset);
36+
add_explicit_alias(&mut actions, &file, offset);
3237
Some(actions)
3338
}
3439

@@ -346,7 +351,7 @@ fn unquote_identifier(
346351
return None;
347352
};
348353

349-
let unquoted = unquote(&name_node)?;
354+
let unquoted = unquote_ident(&name_node)?;
350355

351356
actions.push(CodeAction {
352357
title: "Unquote identifier".to_owned(),
@@ -357,45 +362,37 @@ fn unquote_identifier(
357362
Some(())
358363
}
359364

360-
fn unquote(node: &SyntaxNode) -> Option<String> {
361-
let text = node.text().to_string();
362-
363-
if !text.starts_with('"') || !text.ends_with('"') {
364-
return None;
365-
}
366-
367-
let text = &text[1..text.len() - 1];
368-
369-
if is_reserved_word(text) {
370-
return None;
371-
}
365+
// Postgres docs call these output names.
366+
// Postgres' parser calls this a column label.
367+
// Third-party docs call these aliases, so going with that.
368+
fn add_explicit_alias(
369+
actions: &mut Vec<CodeAction>,
370+
file: &ast::SourceFile,
371+
offset: TextSize,
372+
) -> Option<()> {
373+
let token = token_from_offset(file, offset)?;
374+
let target = token.parent_ancestors().find_map(ast::Target::cast)?;
372375

373-
if text.is_empty() {
376+
if target.as_name().is_some() {
374377
return None;
375378
}
376379

377-
let mut chars = text.chars();
380+
let alias = ColumnName::from_target(target.clone()).and_then(|c| c.0.to_string())?;
378381

379-
// see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
380-
match chars.next() {
381-
Some(c) if c.is_lowercase() || c == '_' => {}
382-
_ => return None,
383-
}
382+
let expr_end = target.expr().map(|e| e.syntax().text_range().end())?;
384383

385-
for c in chars {
386-
if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
387-
continue;
388-
}
389-
return None;
390-
}
384+
let quoted_alias = quote_column_alias(&alias);
385+
// Postgres docs recommend either using `as` or quoting the name. I think
386+
// `as` looks a bit nicer.
387+
let replacement = format!(" as {}", quoted_alias);
391388

392-
Some(text.to_string())
393-
}
389+
actions.push(CodeAction {
390+
title: "Add explicit alias".to_owned(),
391+
edits: vec![Edit::insert(replacement, expr_end)],
392+
kind: ActionKind::RefactorRewrite,
393+
});
394394

395-
fn is_reserved_word(text: &str) -> bool {
396-
RESERVED_KEYWORDS
397-
.binary_search(&text.to_lowercase().as_str())
398-
.is_ok()
395+
Some(())
399396
}
400397

401398
#[cfg(test)]
@@ -933,4 +930,119 @@ mod test {
933930
@"create table T(x int);"
934931
);
935932
}
933+
934+
#[test]
935+
fn add_explicit_alias_simple_column() {
936+
assert_snapshot!(apply_code_action(
937+
add_explicit_alias,
938+
"select col_na$0me from t;"),
939+
@"select col_name as col_name from t;"
940+
);
941+
}
942+
943+
#[test]
944+
fn add_explicit_alias_quoted_identifier() {
945+
assert_snapshot!(apply_code_action(
946+
add_explicit_alias,
947+
r#"select "b"$0 from t;"#),
948+
@r#"select "b" as b from t;"#
949+
);
950+
}
951+
952+
#[test]
953+
fn add_explicit_alias_field_expr() {
954+
assert_snapshot!(apply_code_action(
955+
add_explicit_alias,
956+
"select t.col$0umn from t;"),
957+
@"select t.column as column from t;"
958+
);
959+
}
960+
961+
#[test]
962+
fn add_explicit_alias_function_call() {
963+
assert_snapshot!(apply_code_action(
964+
add_explicit_alias,
965+
"select cou$0nt(*) from t;"),
966+
@"select count(*) as count from t;"
967+
);
968+
}
969+
970+
#[test]
971+
fn add_explicit_alias_cast_to_type() {
972+
assert_snapshot!(apply_code_action(
973+
add_explicit_alias,
974+
"select '1'::bigi$0nt from t;"),
975+
@"select '1'::bigint as int8 from t;"
976+
);
977+
}
978+
979+
#[test]
980+
fn add_explicit_alias_cast_column() {
981+
assert_snapshot!(apply_code_action(
982+
add_explicit_alias,
983+
"select col_na$0me::text from t;"),
984+
@"select col_name::text as col_name from t;"
985+
);
986+
}
987+
988+
#[test]
989+
fn add_explicit_alias_case_expr() {
990+
assert_snapshot!(apply_code_action(
991+
add_explicit_alias,
992+
"select ca$0se when true then 'a' end from t;"),
993+
@"select case when true then 'a' end as case from t;"
994+
);
995+
}
996+
997+
#[test]
998+
fn add_explicit_alias_case_with_else() {
999+
assert_snapshot!(apply_code_action(
1000+
add_explicit_alias,
1001+
"select ca$0se when true then 'a' else now()::text end from t;"),
1002+
@"select case when true then 'a' else now()::text end as now from t;"
1003+
);
1004+
}
1005+
1006+
#[test]
1007+
fn add_explicit_alias_array() {
1008+
assert_snapshot!(apply_code_action(
1009+
add_explicit_alias,
1010+
"select arr$0ay[1, 2, 3] from t;"),
1011+
@"select array[1, 2, 3] as array from t;"
1012+
);
1013+
}
1014+
1015+
#[test]
1016+
fn add_explicit_alias_not_applicable_already_has_alias() {
1017+
assert!(code_action_not_applicable(
1018+
add_explicit_alias,
1019+
"select col_name$0 as foo from t;"
1020+
));
1021+
}
1022+
1023+
#[test]
1024+
fn add_explicit_alias_unknown_column() {
1025+
assert_snapshot!(apply_code_action(
1026+
add_explicit_alias,
1027+
"select 1 $0+ 2 from t;"),
1028+
@r#"select 1 + 2 as "?column?" from t;"#
1029+
);
1030+
}
1031+
1032+
#[test]
1033+
fn add_explicit_alias_not_applicable_star() {
1034+
assert!(code_action_not_applicable(
1035+
add_explicit_alias,
1036+
"select $0* from t;"
1037+
));
1038+
}
1039+
1040+
#[test]
1041+
fn add_explicit_alias_literal() {
1042+
assert_snapshot!(apply_code_action(
1043+
add_explicit_alias,
1044+
"select 'foo$0' from t;"),
1045+
@r#"select 'foo' as "?column?" from t;"#
1046+
);
1047+
}
9361048
}

crates/squawk_ide/src/column_name.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ pub(crate) enum ColumnName {
3030
}
3131

3232
impl ColumnName {
33-
#[allow(dead_code)]
3433
pub(crate) fn from_target(target: ast::Target) -> Option<(ColumnName, SyntaxNode)> {
3534
if let Some(as_name) = target.as_name()
3635
&& let Some(name_node) = as_name.name()
@@ -55,6 +54,16 @@ impl ColumnName {
5554
ColumnName::Column(name)
5655
}
5756
}
57+
58+
pub(crate) fn to_string(&self) -> Option<String> {
59+
match self {
60+
ColumnName::Column(string) => Some(string.to_string()),
61+
ColumnName::Star => None,
62+
ColumnName::UnknownColumn(c) => {
63+
Some(c.clone().unwrap_or_else(|| "?column?".to_string()))
64+
}
65+
}
66+
}
5867
}
5968

6069
fn name_from_type(ty: ast::Type, unknown_column: bool) -> Option<(ColumnName, SyntaxNode)> {
@@ -174,10 +183,9 @@ fn name_from_name_ref(name_ref: ast::NameRef, in_type: bool) -> Option<(ColumnNa
174183
}
175184
}
176185
}
177-
return Some((
178-
ColumnName::Column(name_ref.text().to_string()),
179-
name_ref.syntax().clone(),
180-
));
186+
let text = name_ref.text();
187+
let normalized = normalize_identifier(&text);
188+
return Some((ColumnName::Column(normalized), name_ref.syntax().clone()));
181189
}
182190

183191
/*
@@ -418,11 +426,7 @@ fn examples() {
418426
.unwrap();
419427

420428
ColumnName::from_target(target)
421-
.map(|x| match x.0 {
422-
ColumnName::Column(string) => string,
423-
ColumnName::Star => unreachable!(),
424-
ColumnName::UnknownColumn(c) => c.unwrap_or_else(|| "?column?".to_string()),
425-
})
429+
.and_then(|x| x.0.to_string())
426430
.unwrap()
427431
}
428432
}

crates/squawk_ide/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod goto_definition;
99
pub mod hover;
1010
pub mod inlay_hints;
1111
mod offsets;
12+
mod quote;
1213
mod resolve;
1314
mod scope;
1415
mod symbols;

crates/squawk_ide/src/quote.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use squawk_syntax::SyntaxNode;
2+
3+
use crate::generated::keywords::RESERVED_KEYWORDS;
4+
5+
pub(crate) fn quote_column_alias(text: &str) -> String {
6+
if needs_quoting(text) {
7+
format!(r#""{}""#, text.replace('"', r#""""#))
8+
} else {
9+
text.to_string()
10+
}
11+
}
12+
13+
pub(crate) fn unquote_ident(node: &SyntaxNode) -> Option<String> {
14+
let text = node.text().to_string();
15+
16+
if !text.starts_with('"') || !text.ends_with('"') {
17+
return None;
18+
}
19+
20+
let text = &text[1..text.len() - 1];
21+
22+
if is_reserved_word(text) {
23+
return None;
24+
}
25+
26+
if text.is_empty() {
27+
return None;
28+
}
29+
30+
let mut chars = text.chars();
31+
32+
// see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
33+
match chars.next() {
34+
Some(c) if c.is_lowercase() || c == '_' => {}
35+
_ => return None,
36+
}
37+
38+
for c in chars {
39+
if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
40+
continue;
41+
}
42+
return None;
43+
}
44+
45+
Some(text.to_string())
46+
}
47+
48+
fn needs_quoting(text: &str) -> bool {
49+
if text.is_empty() {
50+
return true;
51+
}
52+
53+
// Column labels in AS clauses allow all keywords, so we don't need to check
54+
// for reserved words. See PostgreSQL grammar:
55+
// ColLabel: IDENT | unreserved_keyword | col_name_keyword | type_func_name_keyword | reserved_keyword
56+
57+
let mut chars = text.chars();
58+
59+
match chars.next() {
60+
Some(c) if c.is_lowercase() || c == '_' => {}
61+
_ => return true,
62+
}
63+
64+
for c in chars {
65+
if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
66+
continue;
67+
}
68+
return true;
69+
}
70+
71+
false
72+
}
73+
74+
pub(crate) fn is_reserved_word(text: &str) -> bool {
75+
RESERVED_KEYWORDS
76+
.binary_search(&text.to_lowercase().as_str())
77+
.is_ok()
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
84+
#[test]
85+
fn quote_column_alias_handles_embedded_quotes() {
86+
assert_eq!(quote_column_alias(r#"foo"bar"#), r#""foo""bar""#);
87+
}
88+
89+
#[test]
90+
fn quote_column_alias_doesnt_quote_reserved_words() {
91+
// Keywords are allowed as column labels in AS clauses
92+
assert_eq!(quote_column_alias("case"), "case");
93+
assert_eq!(quote_column_alias("array"), "array");
94+
}
95+
96+
#[test]
97+
fn quote_column_alias_doesnt_quote_simple_identifiers() {
98+
assert_eq!(quote_column_alias("col_name"), "col_name");
99+
}
100+
101+
#[test]
102+
fn quote_column_alias_handles_special_column_name() {
103+
assert_eq!(quote_column_alias("?column?"), r#""?column?""#);
104+
}
105+
}

0 commit comments

Comments
 (0)