Skip to main content

flowscope_core/linter/rules/
al_008.rs

1//! LINT_AL_008: Unique column alias.
2//!
3//! Column aliases should be unique in each SELECT projection.
4
5use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Issue};
8use sqlparser::ast::{
9    Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins,
10};
11use std::collections::HashSet;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14enum AliasCaseCheck {
15    Dialect,
16    CaseInsensitive,
17    QuotedCsNakedUpper,
18    QuotedCsNakedLower,
19    CaseSensitive,
20}
21
22impl AliasCaseCheck {
23    fn from_config(config: &LintConfig) -> Self {
24        match config
25            .rule_option_str(issue_codes::LINT_AL_008, "alias_case_check")
26            .unwrap_or("dialect")
27            .to_ascii_lowercase()
28            .as_str()
29        {
30            "case_insensitive" => Self::CaseInsensitive,
31            "quoted_cs_naked_upper" => Self::QuotedCsNakedUpper,
32            "quoted_cs_naked_lower" => Self::QuotedCsNakedLower,
33            "case_sensitive" => Self::CaseSensitive,
34            _ => Self::Dialect,
35        }
36    }
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40struct ProjectionAlias {
41    name: String,
42    quoted: bool,
43}
44
45pub struct AliasingUniqueColumn {
46    alias_case_check: AliasCaseCheck,
47}
48
49impl AliasingUniqueColumn {
50    pub fn from_config(config: &LintConfig) -> Self {
51        Self {
52            alias_case_check: AliasCaseCheck::from_config(config),
53        }
54    }
55}
56
57impl Default for AliasingUniqueColumn {
58    fn default() -> Self {
59        Self {
60            alias_case_check: AliasCaseCheck::Dialect,
61        }
62    }
63}
64
65impl LintRule for AliasingUniqueColumn {
66    fn code(&self) -> &'static str {
67        issue_codes::LINT_AL_008
68    }
69
70    fn name(&self) -> &'static str {
71        "Unique column alias"
72    }
73
74    fn description(&self) -> &'static str {
75        "Column aliases should be unique within each clause."
76    }
77
78    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
79        let duplicate = first_duplicate_column_alias_in_statement(statement, self.alias_case_check)
80            .or_else(|| {
81                fallback_duplicate_column_alias_in_sql(ctx.statement_sql(), self.alias_case_check)
82            });
83
84        if duplicate.is_none() {
85            return Vec::new();
86        }
87
88        vec![Issue::warning(
89            issue_codes::LINT_AL_008,
90            "Column aliases should be unique within SELECT projection.",
91        )
92        .with_statement(ctx.statement_index)]
93    }
94}
95
96fn first_duplicate_column_alias_in_statement(
97    statement: &Statement,
98    alias_case_check: AliasCaseCheck,
99) -> Option<String> {
100    match statement {
101        Statement::Query(query) => first_duplicate_column_alias_in_query(query, alias_case_check),
102        Statement::Insert(insert) => insert
103            .source
104            .as_deref()
105            .and_then(|query| first_duplicate_column_alias_in_query(query, alias_case_check)),
106        Statement::CreateView { query, .. } => {
107            first_duplicate_column_alias_in_query(query, alias_case_check)
108        }
109        Statement::CreateTable(create) => create
110            .query
111            .as_deref()
112            .and_then(|query| first_duplicate_column_alias_in_query(query, alias_case_check)),
113        _ => None,
114    }
115}
116
117fn first_duplicate_column_alias_in_query(
118    query: &Query,
119    alias_case_check: AliasCaseCheck,
120) -> Option<String> {
121    if let Some(with) = &query.with {
122        for cte in &with.cte_tables {
123            if let Some(duplicate) =
124                first_duplicate_column_alias_in_query(&cte.query, alias_case_check)
125            {
126                return Some(duplicate);
127            }
128        }
129    }
130
131    first_duplicate_column_alias_in_set_expr(&query.body, alias_case_check)
132}
133
134fn first_duplicate_column_alias_in_set_expr(
135    set_expr: &SetExpr,
136    alias_case_check: AliasCaseCheck,
137) -> Option<String> {
138    match set_expr {
139        SetExpr::Select(select) => first_duplicate_column_alias_in_select(select, alias_case_check),
140        SetExpr::Query(query) => first_duplicate_column_alias_in_query(query, alias_case_check),
141        SetExpr::SetOperation { left, right, .. } => {
142            first_duplicate_column_alias_in_set_expr(left, alias_case_check)
143                .or_else(|| first_duplicate_column_alias_in_set_expr(right, alias_case_check))
144        }
145        SetExpr::Insert(statement)
146        | SetExpr::Update(statement)
147        | SetExpr::Delete(statement)
148        | SetExpr::Merge(statement) => {
149            first_duplicate_column_alias_in_statement(statement, alias_case_check)
150        }
151        _ => None,
152    }
153}
154
155fn first_duplicate_column_alias_in_select(
156    select: &Select,
157    alias_case_check: AliasCaseCheck,
158) -> Option<String> {
159    let mut aliases = Vec::new();
160    for item in &select.projection {
161        if let Some(alias) = projected_column_alias(item) {
162            aliases.push(alias);
163        }
164    }
165
166    if let Some(duplicate) = first_duplicate_alias(&aliases, alias_case_check) {
167        return Some(duplicate);
168    }
169
170    for table_with_joins in &select.from {
171        if let Some(duplicate) = first_duplicate_column_alias_in_table_with_joins_children(
172            table_with_joins,
173            alias_case_check,
174        ) {
175            return Some(duplicate);
176        }
177    }
178
179    None
180}
181
182fn projected_column_alias(item: &SelectItem) -> Option<ProjectionAlias> {
183    match item {
184        SelectItem::ExprWithAlias { alias, .. } => Some(ProjectionAlias {
185            name: alias.value.clone(),
186            quoted: alias.quote_style.is_some(),
187        }),
188        SelectItem::UnnamedExpr(Expr::Identifier(identifier)) => Some(ProjectionAlias {
189            name: identifier.value.clone(),
190            quoted: identifier.quote_style.is_some(),
191        }),
192        SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts)) => {
193            parts.last().map(|part| ProjectionAlias {
194                name: part.value.clone(),
195                quoted: part.quote_style.is_some(),
196            })
197        }
198        _ => None,
199    }
200}
201
202fn first_duplicate_column_alias_in_table_with_joins_children(
203    table_with_joins: &TableWithJoins,
204    alias_case_check: AliasCaseCheck,
205) -> Option<String> {
206    first_duplicate_column_alias_in_table_factor_children(
207        &table_with_joins.relation,
208        alias_case_check,
209    )
210    .or_else(|| {
211        for join in &table_with_joins.joins {
212            if let Some(duplicate) = first_duplicate_column_alias_in_table_factor_children(
213                &join.relation,
214                alias_case_check,
215            ) {
216                return Some(duplicate);
217            }
218        }
219        None
220    })
221}
222
223fn first_duplicate_column_alias_in_table_factor_children(
224    table_factor: &TableFactor,
225    alias_case_check: AliasCaseCheck,
226) -> Option<String> {
227    match table_factor {
228        TableFactor::Derived { subquery, .. } => {
229            first_duplicate_column_alias_in_query(subquery, alias_case_check)
230        }
231        TableFactor::NestedJoin {
232            table_with_joins, ..
233        } => first_duplicate_column_alias_in_nested_scope(table_with_joins, alias_case_check),
234        TableFactor::Pivot { table, .. }
235        | TableFactor::Unpivot { table, .. }
236        | TableFactor::MatchRecognize { table, .. } => {
237            first_duplicate_column_alias_in_table_factor_children(table, alias_case_check)
238        }
239        _ => None,
240    }
241}
242
243fn first_duplicate_column_alias_in_nested_scope(
244    table_with_joins: &TableWithJoins,
245    alias_case_check: AliasCaseCheck,
246) -> Option<String> {
247    first_duplicate_column_alias_in_table_with_joins_children(table_with_joins, alias_case_check)
248}
249
250fn first_duplicate_alias(
251    values: &[ProjectionAlias],
252    alias_case_check: AliasCaseCheck,
253) -> Option<String> {
254    let mut seen: Vec<&ProjectionAlias> = Vec::new();
255    let mut seen_case_insensitive = HashSet::new();
256
257    for value in values {
258        // Fast-path for the common case-insensitive mode.
259        if matches!(alias_case_check, AliasCaseCheck::CaseInsensitive) {
260            let key = value.name.to_ascii_uppercase();
261            if !seen_case_insensitive.insert(key) {
262                return Some(value.name.clone());
263            }
264            continue;
265        }
266
267        let is_duplicate = seen
268            .iter()
269            .any(|existing| aliases_match(existing, value, alias_case_check));
270        if is_duplicate {
271            return Some(value.name.clone());
272        }
273        seen.push(value);
274    }
275
276    None
277}
278
279fn fallback_duplicate_column_alias_in_sql(
280    sql: &str,
281    alias_case_check: AliasCaseCheck,
282) -> Option<String> {
283    let trimmed = sql.trim();
284    if trimmed.len() < 6 || !trimmed[..6].eq_ignore_ascii_case("select") {
285        return None;
286    }
287
288    // Keep the fallback narrow: only used for parser-fragment SELECT lists
289    // that end with a trailing comma (e.g. SQLFluff AL08 test_fail_locs).
290    if !trimmed.ends_with(',') {
291        return None;
292    }
293
294    let projection_sql = trimmed[6..].trim_start();
295    let aliases = fallback_projection_aliases(projection_sql);
296    if aliases.is_empty() {
297        return None;
298    }
299
300    first_duplicate_alias(&aliases, alias_case_check)
301}
302
303fn fallback_projection_aliases(projection_sql: &str) -> Vec<ProjectionAlias> {
304    projection_sql
305        .lines()
306        .filter_map(|line| {
307            let expr = line.trim().trim_end_matches(',').trim();
308            if expr.is_empty() {
309                return None;
310            }
311            projection_alias_from_fragment(expr)
312        })
313        .collect()
314}
315
316fn projection_alias_from_fragment(expr: &str) -> Option<ProjectionAlias> {
317    if expr.contains("{{") || expr.contains("{%") || expr.contains("{#") {
318        return None;
319    }
320
321    let parts = expr.split_whitespace().collect::<Vec<_>>();
322    if parts.is_empty() {
323        return None;
324    }
325
326    let alias_raw = if parts.len() >= 2 {
327        parts[parts.len() - 1]
328    } else {
329        parts[0]
330    };
331
332    let quoted =
333        alias_raw.starts_with('"') || alias_raw.starts_with('`') || alias_raw.starts_with('[');
334    let name = alias_raw
335        .trim_matches(|ch| matches!(ch, '"' | '`' | '[' | ']'))
336        .to_string();
337    if name.is_empty() {
338        return None;
339    }
340
341    Some(ProjectionAlias { name, quoted })
342}
343
344fn aliases_match(
345    left: &ProjectionAlias,
346    right: &ProjectionAlias,
347    alias_case_check: AliasCaseCheck,
348) -> bool {
349    match alias_case_check {
350        AliasCaseCheck::CaseInsensitive => left.name.eq_ignore_ascii_case(&right.name),
351        AliasCaseCheck::CaseSensitive => left.name == right.name,
352        AliasCaseCheck::Dialect => {
353            if left.quoted || right.quoted {
354                left.name == right.name
355            } else {
356                left.name.eq_ignore_ascii_case(&right.name)
357            }
358        }
359        AliasCaseCheck::QuotedCsNakedUpper | AliasCaseCheck::QuotedCsNakedLower => {
360            normalize_alias_for_mode(left, alias_case_check)
361                == normalize_alias_for_mode(right, alias_case_check)
362        }
363    }
364}
365
366fn normalize_alias_for_mode(alias: &ProjectionAlias, mode: AliasCaseCheck) -> String {
367    match mode {
368        AliasCaseCheck::QuotedCsNakedUpper => {
369            if alias.quoted {
370                alias.name.clone()
371            } else {
372                alias.name.to_ascii_uppercase()
373            }
374        }
375        AliasCaseCheck::QuotedCsNakedLower => {
376            if alias.quoted {
377                alias.name.clone()
378            } else {
379                alias.name.to_ascii_lowercase()
380            }
381        }
382        _ => alias.name.clone(),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::parser::parse_sql;
390
391    fn run(sql: &str) -> Vec<Issue> {
392        let statements = parse_sql(sql).expect("parse");
393        let rule = AliasingUniqueColumn::default();
394        statements
395            .iter()
396            .enumerate()
397            .flat_map(|(index, statement)| {
398                rule.check(
399                    statement,
400                    &LintContext {
401                        sql,
402                        statement_range: 0..sql.len(),
403                        statement_index: index,
404                    },
405                )
406            })
407            .collect()
408    }
409
410    #[test]
411    fn flags_duplicate_projection_alias() {
412        let issues = run("select a as x, b as x from t");
413        assert_eq!(issues.len(), 1);
414        assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
415    }
416
417    #[test]
418    fn allows_unique_projection_aliases() {
419        let issues = run("select a as x, b as y from t");
420        assert!(issues.is_empty());
421    }
422
423    #[test]
424    fn allows_same_alias_in_different_cte_scopes() {
425        let sql = "with a as (select col as x from t1), b as (select col as x from t2) select * from a join b on a.x = b.x";
426        let issues = run(sql);
427        assert!(issues.is_empty());
428    }
429
430    #[test]
431    fn flags_duplicate_alias_in_nested_subquery() {
432        let sql = "select * from (select a as x, b as x from t) s";
433        let issues = run(sql);
434        assert_eq!(issues.len(), 1);
435    }
436
437    #[test]
438    fn flags_duplicate_unaliased_column_reference() {
439        let issues = run("select foo, foo from t");
440        assert_eq!(issues.len(), 1);
441        assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
442    }
443
444    #[test]
445    fn flags_alias_collision_with_unaliased_reference() {
446        let issues = run("select foo, a as foo from t");
447        assert_eq!(issues.len(), 1);
448        assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
449    }
450
451    #[test]
452    fn default_dialect_mode_does_not_flag_quoted_case_mismatch() {
453        let issues = run("select \"A\", a from t");
454        assert!(issues.is_empty());
455    }
456
457    #[test]
458    fn alias_case_check_case_sensitive_allows_case_mismatch() {
459        let sql = "select a, A from t";
460        let statements = parse_sql(sql).expect("parse");
461        let rule = AliasingUniqueColumn::from_config(&LintConfig {
462            enabled: true,
463            disabled_rules: vec![],
464            rule_configs: std::collections::BTreeMap::from([(
465                "aliasing.unique.column".to_string(),
466                serde_json::json!({"alias_case_check": "case_sensitive"}),
467            )]),
468        });
469        let issues = rule.check(
470            &statements[0],
471            &LintContext {
472                sql,
473                statement_range: 0..sql.len(),
474                statement_index: 0,
475            },
476        );
477        assert!(issues.is_empty());
478    }
479
480    #[test]
481    fn alias_case_check_case_sensitive_flags_exact_duplicates() {
482        let sql = "select a, a from t";
483        let statements = parse_sql(sql).expect("parse");
484        let rule = AliasingUniqueColumn::from_config(&LintConfig {
485            enabled: true,
486            disabled_rules: vec![],
487            rule_configs: std::collections::BTreeMap::from([(
488                "LINT_AL_008".to_string(),
489                serde_json::json!({"alias_case_check": "case_sensitive"}),
490            )]),
491        });
492        let issues = rule.check(
493            &statements[0],
494            &LintContext {
495                sql,
496                statement_range: 0..sql.len(),
497                statement_index: 0,
498            },
499        );
500        assert_eq!(issues.len(), 1);
501    }
502
503    #[test]
504    fn alias_case_check_quoted_cs_naked_upper_flags_upper_fold_match() {
505        let sql = "select \"FOO\", foo from t";
506        let statements = parse_sql(sql).expect("parse");
507        let rule = AliasingUniqueColumn::from_config(&LintConfig {
508            enabled: true,
509            disabled_rules: vec![],
510            rule_configs: std::collections::BTreeMap::from([(
511                "aliasing.unique.column".to_string(),
512                serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
513            )]),
514        });
515        let issues = rule.check(
516            &statements[0],
517            &LintContext {
518                sql,
519                statement_range: 0..sql.len(),
520                statement_index: 0,
521            },
522        );
523        assert_eq!(issues.len(), 1);
524    }
525
526    #[test]
527    fn alias_case_check_quoted_cs_naked_upper_allows_nonmatching_quoted_case() {
528        let sql = "select \"foo\", foo from t";
529        let statements = parse_sql(sql).expect("parse");
530        let rule = AliasingUniqueColumn::from_config(&LintConfig {
531            enabled: true,
532            disabled_rules: vec![],
533            rule_configs: std::collections::BTreeMap::from([(
534                "aliasing.unique.column".to_string(),
535                serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
536            )]),
537        });
538        let issues = rule.check(
539            &statements[0],
540            &LintContext {
541                sql,
542                statement_range: 0..sql.len(),
543                statement_index: 0,
544            },
545        );
546        assert!(issues.is_empty());
547    }
548
549    #[test]
550    fn alias_case_check_quoted_cs_naked_lower_flags_lower_fold_match() {
551        let sql = "select \"foo\", FOO from t";
552        let statements = parse_sql(sql).expect("parse");
553        let rule = AliasingUniqueColumn::from_config(&LintConfig {
554            enabled: true,
555            disabled_rules: vec![],
556            rule_configs: std::collections::BTreeMap::from([(
557                "aliasing.unique.column".to_string(),
558                serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
559            )]),
560        });
561        let issues = rule.check(
562            &statements[0],
563            &LintContext {
564                sql,
565                statement_range: 0..sql.len(),
566                statement_index: 0,
567            },
568        );
569        assert_eq!(issues.len(), 1);
570    }
571
572    #[test]
573    fn flags_multiple_reused_aliases_in_projection() {
574        // SQLFluff: test_fail_locs — `foo` reused on lines 3 and 6; `bar` on line 5.
575        // Note: fixture SQL has trailing comma and no FROM clause.
576        let sql = "select\n  foo,\n  b as foo,\n  c as bar,\n  bar,\n  d foo\nfrom t";
577        let issues = run(sql);
578        assert_eq!(issues.len(), 1);
579        assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
580    }
581
582    #[test]
583    fn statementless_trailing_comma_fragment_detects_duplicate_aliases() {
584        let sql = "select\n  foo,\n  b as foo,\n  c as bar,\n  bar,\n  d foo,\n";
585        let synthetic = parse_sql("SELECT 1").expect("parse");
586        let rule = AliasingUniqueColumn::default();
587        let issues = rule.check(
588            &synthetic[0],
589            &LintContext {
590                sql,
591                statement_range: 0..sql.len(),
592                statement_index: 0,
593            },
594        );
595        assert_eq!(issues.len(), 1);
596        assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
597    }
598
599    #[test]
600    fn alias_case_check_quoted_cs_naked_lower_allows_nonmatching_quoted_case() {
601        let sql = "select \"FOO\", FOO from t";
602        let statements = parse_sql(sql).expect("parse");
603        let rule = AliasingUniqueColumn::from_config(&LintConfig {
604            enabled: true,
605            disabled_rules: vec![],
606            rule_configs: std::collections::BTreeMap::from([(
607                "aliasing.unique.column".to_string(),
608                serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
609            )]),
610        });
611        let issues = rule.check(
612            &statements[0],
613            &LintContext {
614                sql,
615                statement_range: 0..sql.len(),
616                statement_index: 0,
617            },
618        );
619        assert!(issues.is_empty());
620    }
621}