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