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