flowscope_core/linter/rules/
al_003.rs1use crate::linter::config::LintConfig;
8use crate::linter::rule::{LintContext, LintRule};
9use crate::types::{issue_codes, Issue};
10use sqlparser::ast::*;
11
12pub struct ImplicitAlias {
13 allow_scalar: bool,
14}
15
16impl ImplicitAlias {
17 pub fn from_config(config: &LintConfig) -> Self {
18 Self {
19 allow_scalar: config
20 .rule_option_bool(issue_codes::LINT_AL_003, "allow_scalar")
21 .unwrap_or(true),
22 }
23 }
24}
25
26impl Default for ImplicitAlias {
27 fn default() -> Self {
28 Self { allow_scalar: true }
29 }
30}
31
32impl LintRule for ImplicitAlias {
33 fn code(&self) -> &'static str {
34 issue_codes::LINT_AL_003
35 }
36
37 fn name(&self) -> &'static str {
38 "Implicit alias"
39 }
40
41 fn description(&self) -> &'static str {
42 "Column expression without alias. Use explicit `AS` clause."
43 }
44
45 fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
46 let mut issues = Vec::new();
47 check_statement(stmt, ctx, self.allow_scalar, &mut issues);
48 issues
49 }
50}
51
52fn check_statement(
53 stmt: &Statement,
54 ctx: &LintContext,
55 allow_scalar: bool,
56 issues: &mut Vec<Issue>,
57) {
58 match stmt {
59 Statement::Query(q) => check_query(q, ctx, allow_scalar, issues, false),
60 Statement::Insert(ins) => {
61 if let Some(ref source) = ins.source {
62 check_query(source, ctx, allow_scalar, issues, false);
63 }
64 }
65 Statement::CreateView(CreateView { query, .. }) => {
66 check_query(query, ctx, allow_scalar, issues, false)
67 }
68 Statement::CreateTable(create) => {
69 if let Some(ref q) = create.query {
70 check_query(q, ctx, allow_scalar, issues, false);
71 }
72 }
73 _ => {}
74 }
75}
76
77fn check_query(
78 query: &Query,
79 ctx: &LintContext,
80 allow_scalar: bool,
81 issues: &mut Vec<Issue>,
82 has_cte_column_list: bool,
83) {
84 if let Some(ref with) = query.with {
85 for cte in &with.cte_tables {
86 let cte_has_columns = !cte.alias.columns.is_empty();
90 check_query(&cte.query, ctx, allow_scalar, issues, cte_has_columns);
91 }
92 }
93 check_set_expr(&query.body, ctx, allow_scalar, issues, has_cte_column_list);
94}
95
96fn check_set_expr(
97 body: &SetExpr,
98 ctx: &LintContext,
99 allow_scalar: bool,
100 issues: &mut Vec<Issue>,
101 has_cte_column_list: bool,
102) {
103 match body {
104 SetExpr::Select(select) => {
105 if has_cte_column_list {
108 return;
109 }
110
111 for item in &select.projection {
112 if let SelectItem::UnnamedExpr(expr) = item {
113 if is_computed(expr) || (!allow_scalar && is_scalar_literal(expr)) {
114 let expr_str = format!("{expr}");
115 issues.push(
116 Issue::info(
117 issue_codes::LINT_AL_003,
118 format!(
119 "Expression '{}' has no explicit alias. Add AS <name>.",
120 truncate(&expr_str, 60)
121 ),
122 )
123 .with_statement(ctx.statement_index),
124 );
125 }
126 }
127 }
128 }
129 SetExpr::Query(q) => check_query(q, ctx, allow_scalar, issues, has_cte_column_list),
130 SetExpr::SetOperation { left, right, .. } => {
131 check_set_expr(left, ctx, allow_scalar, issues, has_cte_column_list);
132 check_set_expr(right, ctx, false, issues, has_cte_column_list);
136 }
137 SetExpr::Insert(stmt)
138 | SetExpr::Update(stmt)
139 | SetExpr::Delete(stmt)
140 | SetExpr::Merge(stmt) => check_statement(stmt, ctx, allow_scalar, issues),
141 _ => {}
142 }
143}
144
145fn is_computed(expr: &Expr) -> bool {
152 match expr {
153 Expr::Identifier(_) | Expr::CompoundIdentifier(_) | Expr::Value(_) => false,
154 Expr::Cast {
156 kind: CastKind::DoubleColon,
157 expr: inner,
158 ..
159 } => is_computed(inner),
160 Expr::Nested(inner) => is_computed(inner),
162 _ if contains_columns_macro(expr) => false,
167 _ => true,
168 }
169}
170
171fn contains_columns_macro(expr: &Expr) -> bool {
174 match expr {
175 Expr::Function(func) => {
176 let is_columns = func.name.0.len() == 1
177 && func.name.0[0]
178 .as_ident()
179 .is_some_and(|id| id.value.eq_ignore_ascii_case("columns"));
180 if is_columns {
181 return true;
182 }
183 if let FunctionArguments::List(ref arg_list) = func.args {
184 arg_list.args.iter().any(|arg| match arg {
185 FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => contains_columns_macro(e),
186 _ => false,
187 })
188 } else {
189 false
190 }
191 }
192 Expr::Nested(inner) => contains_columns_macro(inner),
193 _ => false,
194 }
195}
196
197fn is_scalar_literal(expr: &Expr) -> bool {
198 matches!(expr, Expr::Value(_))
199}
200
201fn truncate(s: &str, max_len: usize) -> &str {
202 match s.char_indices().nth(max_len) {
203 Some((idx, _)) => &s[..idx],
204 None => s,
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::parser::parse_sql;
212
213 fn check_sql_with_rule(sql: &str, rule: ImplicitAlias) -> Vec<Issue> {
214 let stmts = parse_sql(sql).unwrap();
215 let ctx = LintContext {
216 sql,
217 statement_range: 0..sql.len(),
218 statement_index: 0,
219 };
220 let mut issues = Vec::new();
221 for stmt in &stmts {
222 issues.extend(rule.check(stmt, &ctx));
223 }
224 issues
225 }
226
227 fn check_sql(sql: &str) -> Vec<Issue> {
228 check_sql_with_rule(sql, ImplicitAlias::default())
229 }
230
231 #[test]
232 fn test_implicit_alias_detected() {
233 let issues = check_sql("SELECT a + b FROM t");
234 assert_eq!(issues.len(), 1);
235 assert_eq!(issues[0].code, "LINT_AL_003");
236 }
237
238 #[test]
239 fn test_explicit_alias_ok() {
240 let issues = check_sql("SELECT a + b AS total FROM t");
241 assert!(issues.is_empty());
242 }
243
244 #[test]
245 fn test_simple_column_ok() {
246 let issues = check_sql("SELECT a, b FROM t");
247 assert!(issues.is_empty());
248 }
249
250 #[test]
251 fn test_function_without_alias() {
252 let issues = check_sql("SELECT COUNT(*) FROM t");
253 assert_eq!(issues.len(), 1);
254 }
255
256 #[test]
257 fn test_function_with_alias_ok() {
258 let issues = check_sql("SELECT COUNT(*) AS cnt FROM t");
259 assert!(issues.is_empty());
260 }
261
262 #[test]
265 fn test_cast_without_alias() {
266 let issues = check_sql("SELECT CAST(x AS INT) FROM t");
267 assert_eq!(issues.len(), 1);
268 }
269
270 #[test]
271 fn test_cast_with_alias_ok() {
272 let issues = check_sql("SELECT CAST(x AS INT) AS x_int FROM t");
273 assert!(issues.is_empty());
274 }
275
276 #[test]
277 fn test_star_ok() {
278 let issues = check_sql("SELECT * FROM t");
279 assert!(issues.is_empty());
280 }
281
282 #[test]
283 fn test_qualified_star_ok() {
284 let issues = check_sql("SELECT t.* FROM t");
285 assert!(issues.is_empty());
286 }
287
288 #[test]
289 fn test_literal_ok() {
290 let issues = check_sql("SELECT 1 FROM t");
291 assert!(issues.is_empty());
292 }
293
294 #[test]
295 fn test_string_literal_ok() {
296 let issues = check_sql("SELECT 'hello' FROM t");
297 assert!(issues.is_empty());
298 }
299
300 #[test]
301 fn test_upper_function_without_alias() {
302 let issues = check_sql("SELECT UPPER(name) FROM t");
303 assert_eq!(issues.len(), 1);
304 }
305
306 #[test]
307 fn test_upper_function_with_alias_ok() {
308 let issues = check_sql("SELECT UPPER(name) AS upper_name FROM t");
309 assert!(issues.is_empty());
310 }
311
312 #[test]
313 fn test_arithmetic_without_alias() {
314 let issues = check_sql("SELECT price * quantity FROM t");
315 assert_eq!(issues.len(), 1);
316 }
317
318 #[test]
319 fn test_multiple_expressions_mixed() {
320 let issues = check_sql("SELECT a + b AS total, c * d FROM t");
322 assert_eq!(issues.len(), 1);
323 }
324
325 #[test]
326 fn test_union_rhs_expression_without_alias_ok() {
327 let issues = check_sql("SELECT a + b AS total FROM t UNION ALL SELECT 0::INT FROM t");
328 assert!(issues.is_empty());
329 }
330
331 #[test]
332 fn test_with_insert_select_expression_without_alias_detected() {
333 let sql = "WITH params AS (SELECT 1) INSERT INTO t(a) SELECT COALESCE(x, 0) FROM src";
334 let issues = check_sql(sql);
335 assert_eq!(issues.len(), 1);
336 assert_eq!(issues[0].code, "LINT_AL_003");
337 }
338
339 #[test]
340 fn test_case_expression_without_alias() {
341 let issues = check_sql("SELECT CASE WHEN x > 0 THEN 'yes' ELSE 'no' END FROM t");
342 assert_eq!(issues.len(), 1);
343 }
344
345 #[test]
346 fn test_case_expression_with_alias_ok() {
347 let issues = check_sql("SELECT CASE WHEN x > 0 THEN 'yes' ELSE 'no' END AS flag FROM t");
348 assert!(issues.is_empty());
349 }
350
351 #[test]
352 fn test_expression_in_cte() {
353 let issues = check_sql("WITH cte AS (SELECT a + b FROM t) SELECT * FROM cte");
354 assert_eq!(issues.len(), 1);
355 }
356
357 #[test]
358 fn test_qualified_column_ok() {
359 let issues = check_sql("SELECT t.a, t.b FROM t");
360 assert!(issues.is_empty());
361 }
362
363 #[test]
364 fn test_non_ascii_expression_truncation_is_utf8_safe() {
365 let sql = format!("SELECT \"{}é\" + 1 FROM t", "a".repeat(58));
366 let issues = check_sql(&sql);
367
368 assert_eq!(issues.len(), 1);
369 assert_eq!(issues[0].code, "LINT_AL_003");
370 }
371
372 #[test]
373 fn test_allow_scalar_false_flags_literals() {
374 let config = LintConfig {
375 enabled: true,
376 disabled_rules: vec![],
377 rule_configs: std::collections::BTreeMap::from([(
378 "aliasing.expression".to_string(),
379 serde_json::json!({"allow_scalar": false}),
380 )]),
381 };
382 let issues = check_sql_with_rule("SELECT 1 FROM t", ImplicitAlias::from_config(&config));
383 assert_eq!(issues.len(), 1);
384 }
385
386 #[test]
387 fn cast_only_column_is_not_computed() {
388 assert!(check_sql("SELECT foo_col::VARCHAR(28) , bar FROM blah").is_empty());
390 }
391
392 #[test]
393 fn double_cast_column_is_not_computed() {
394 assert!(check_sql("SELECT foo_col::INT::VARCHAR , bar FROM blah").is_empty());
396 }
397
398 #[test]
399 fn bracketed_cast_column_is_not_computed() {
400 assert!(check_sql("SELECT (foo_col::INT)::VARCHAR , bar FROM blah").is_empty());
402 }
403
404 #[test]
405 fn cte_with_column_list_skips_alias_check() {
406 let sql = "WITH cte(a, b) AS (SELECT col_a, min(col_b) FROM my_table GROUP BY 1) SELECT a, b FROM cte";
408 assert!(check_sql(sql).is_empty());
409 }
410
411 #[test]
412 fn cast_wrapping_function_is_computed() {
413 assert_eq!(check_sql("SELECT CAST(COUNT(*) AS INT) FROM t").len(), 1);
415 }
416
417 #[test]
418 fn duckdb_columns_macro_ok() {
419 assert!(check_sql("SELECT COLUMNS(c -> c LIKE '%num%'), 1 AS x FROM numbers").is_empty());
421 }
422
423 #[test]
424 fn duckdb_nested_columns_macro_ok() {
425 assert!(
428 check_sql("SELECT MIN(COLUMNS(c -> c LIKE '%num%')), 1 AS x FROM numbers").is_empty()
429 );
430 }
431}