Skip to main content

flowscope_core/linter/rules/
al_003.rs

1//! LINT_AL_003: Implicit column alias.
2//!
3//! Computed expressions in SELECT without an explicit AS alias produce
4//! implementation-dependent column names. Always give computed columns
5//! an explicit alias for clarity and portability.
6
7use 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            // When a CTE has an explicit column list like `cte(a, b)`, the inner
87            // SELECT's column names are bound to those names automatically, so
88            // requiring aliases would be noise.
89            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            // When a CTE has an explicit column list, the inner SELECT's column
106            // names are automatically overridden, so aliases are not required.
107            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            // In set-operation RHS branches, output column names come from the
133            // left side.  SQLFluff still requires aliases on scalar literals in
134            // these branches, so disable the `allow_scalar` exemption here.
135            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
145/// Returns true if the expression is "computed" (not a simple column reference or literal).
146///
147/// Postgres-style `::` casts (`col::TYPE`) preserve the output column name,
148/// so they are treated as non-computed when the inner expression is a simple
149/// reference. Function-style `CAST()` produces implementation-dependent names
150/// and is still treated as computed.
151fn is_computed(expr: &Expr) -> bool {
152    match expr {
153        Expr::Identifier(_) | Expr::CompoundIdentifier(_) | Expr::Value(_) => false,
154        // `col::TYPE` preserves the column name — recurse into the inner expression.
155        Expr::Cast {
156            kind: CastKind::DoubleColon,
157            expr: inner,
158            ..
159        } => is_computed(inner),
160        // Parenthesized expression — check inner.
161        Expr::Nested(inner) => is_computed(inner),
162        // DuckDB `COLUMNS(...)` is a macro that expands to matching column
163        // references at query time. Wrapping it in another function
164        // (e.g. `MIN(COLUMNS(...))`) also expands dynamically, so there is
165        // no single computed column that needs an alias.
166        _ if contains_columns_macro(expr) => false,
167        _ => true,
168    }
169}
170
171/// Returns true when the expression tree contains a DuckDB `COLUMNS()`
172/// macro call at any depth.
173fn 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    // --- Edge cases adopted from sqlfluff AL03 (aliasing.expression) ---
263
264    #[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        // One has alias, one doesn't
321        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        // SQLFluff: test_pass_column_exp_without_alias_if_only_cast
389        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        // SQLFluff: test_pass_column_exp_without_alias_if_only_cast_inc_double_cast
395        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        // SQLFluff: test_pass_column_exp_without_alias_if_bracketed
401        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        // SQLFluff: test_pass_cte_column_list
407        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        // CAST(func()) still needs an alias since func() is computed.
414        assert_eq!(check_sql("SELECT CAST(COUNT(*) AS INT) FROM t").len(), 1);
415    }
416
417    #[test]
418    fn duckdb_columns_macro_ok() {
419        // SQLFluff: test_pass_duckdb_columns_expression
420        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        // SQLFluff: test_pass_duckdb_nested_columns_expression
426        // MIN(COLUMNS(...)) expands dynamically — no single computed column.
427        assert!(
428            check_sql("SELECT MIN(COLUMNS(c -> c LIKE '%num%')), 1 AS x FROM numbers").is_empty()
429        );
430    }
431}