Skip to main content

flowscope_core/linter/rules/
al_005.rs

1//! LINT_AL_005: Unused table alias.
2//!
3//! A table is aliased in a FROM/JOIN clause but the alias is never referenced
4//! anywhere in the query. This may indicate dead code or a copy-paste error.
5
6use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
9use sqlparser::ast::*;
10use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
11use std::collections::{HashMap, 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_005, "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, Hash, PartialEq)]
40struct AliasRef {
41    name: String,
42    quoted: bool,
43    relation_key: Option<String>,
44}
45
46#[derive(Clone, Debug, Eq, Hash, PartialEq)]
47struct QualifierRef {
48    name: String,
49    quoted: bool,
50}
51
52pub struct UnusedTableAlias {
53    alias_case_check: AliasCaseCheck,
54}
55
56impl UnusedTableAlias {
57    pub fn from_config(config: &LintConfig) -> Self {
58        Self {
59            alias_case_check: AliasCaseCheck::from_config(config),
60        }
61    }
62}
63
64impl Default for UnusedTableAlias {
65    fn default() -> Self {
66        Self {
67            alias_case_check: AliasCaseCheck::Dialect,
68        }
69    }
70}
71
72impl LintRule for UnusedTableAlias {
73    fn code(&self) -> &'static str {
74        issue_codes::LINT_AL_005
75    }
76
77    fn name(&self) -> &'static str {
78        "Unused table alias"
79    }
80
81    fn description(&self) -> &'static str {
82        "Tables should not be aliased if that alias is not used."
83    }
84
85    fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
86        let mut issues = Vec::new();
87        match stmt {
88            Statement::Query(q) => check_query(q, self.alias_case_check, ctx, &mut issues),
89            Statement::Insert(ins) => {
90                if let Some(ref source) = ins.source {
91                    check_query(source, self.alias_case_check, ctx, &mut issues);
92                }
93            }
94            Statement::CreateView(CreateView { query, .. }) => {
95                check_query(query, self.alias_case_check, ctx, &mut issues)
96            }
97            Statement::CreateTable(create) => {
98                if let Some(ref q) = create.query {
99                    check_query(q, self.alias_case_check, ctx, &mut issues);
100                }
101            }
102            Statement::Delete(delete) => {
103                check_delete(delete, self.alias_case_check, ctx, &mut issues);
104            }
105            _ => {}
106        }
107
108        if let Some(first_issue) = issues.first_mut() {
109            let autofix_edits: Vec<IssuePatchEdit> = al005_legacy_autofix_edits(
110                ctx.statement_sql(),
111                ctx.dialect(),
112                self.alias_case_check,
113            )
114            .into_iter()
115            .map(|(start, end)| IssuePatchEdit::new(ctx.span_from_statement_offset(start, end), ""))
116            .collect();
117            if !autofix_edits.is_empty() {
118                *first_issue = first_issue
119                    .clone()
120                    .with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
121            }
122        }
123
124        issues
125    }
126}
127
128fn check_query(
129    query: &Query,
130    alias_case_check: AliasCaseCheck,
131    ctx: &LintContext,
132    issues: &mut Vec<Issue>,
133) {
134    if let Some(ref with) = query.with {
135        for cte in &with.cte_tables {
136            check_query(&cte.query, alias_case_check, ctx, issues);
137        }
138    }
139    match query.body.as_ref() {
140        SetExpr::Select(select) => check_select(
141            select,
142            query.order_by.as_ref(),
143            alias_case_check,
144            ctx,
145            issues,
146        ),
147        _ => check_set_expr(&query.body, alias_case_check, ctx, issues),
148    }
149}
150
151fn check_set_expr(
152    body: &SetExpr,
153    alias_case_check: AliasCaseCheck,
154    ctx: &LintContext,
155    issues: &mut Vec<Issue>,
156) {
157    match body {
158        SetExpr::Select(select) => {
159            check_select(select, None, alias_case_check, ctx, issues);
160        }
161        SetExpr::Query(q) => check_query(q, alias_case_check, ctx, issues),
162        SetExpr::SetOperation { left, right, .. } => {
163            check_set_expr(left, alias_case_check, ctx, issues);
164            check_set_expr(right, alias_case_check, ctx, issues);
165        }
166        _ => {}
167    }
168}
169
170fn check_select(
171    select: &Select,
172    order_by: Option<&OrderBy>,
173    alias_case_check: AliasCaseCheck,
174    ctx: &LintContext,
175    issues: &mut Vec<Issue>,
176) {
177    for from_item in &select.from {
178        check_table_factor_subqueries(&from_item.relation, alias_case_check, ctx, issues);
179        for join in &from_item.joins {
180            check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
181        }
182    }
183
184    // Collect aliases -> table names
185    let mut aliases: HashMap<String, AliasRef> = HashMap::new();
186    for from_item in &select.from {
187        collect_aliases(&from_item.relation, ctx.dialect(), &mut aliases);
188        for join in &from_item.joins {
189            collect_aliases(&join.relation, ctx.dialect(), &mut aliases);
190        }
191    }
192
193    if aliases.is_empty() {
194        return;
195    }
196
197    let mut used_prefixes: HashSet<QualifierRef> = HashSet::new();
198    collect_identifier_prefixes_from_select(select, order_by, ctx.dialect(), &mut used_prefixes);
199
200    if matches!(ctx.dialect(), Dialect::Redshift) {
201        if let Some(qualify) = &select.qualify {
202            if include_qualify_alias_references(ctx.dialect(), select) {
203                for alias in aliases.values() {
204                    if redshift_qualify_uses_alias_prefixed_identifier(qualify, &alias.name) {
205                        used_prefixes.insert(QualifierRef {
206                            name: alias.name.clone(),
207                            quoted: alias.quoted,
208                        });
209                    }
210                }
211            }
212        }
213    }
214
215    emit_unused_alias_issues(
216        &aliases,
217        &used_prefixes,
218        alias_case_check,
219        ctx.dialect(),
220        ctx.statement_index,
221        issues,
222    );
223}
224
225fn check_delete(
226    delete: &Delete,
227    alias_case_check: AliasCaseCheck,
228    ctx: &LintContext,
229    issues: &mut Vec<Issue>,
230) {
231    let mut aliases: HashMap<String, AliasRef> = HashMap::new();
232    let mut used_prefixes: HashSet<QualifierRef> = HashSet::new();
233
234    for table in delete_source_tables(delete) {
235        check_table_factor_subqueries(&table.relation, alias_case_check, ctx, issues);
236        collect_aliases(&table.relation, ctx.dialect(), &mut aliases);
237        collect_identifier_prefixes_from_table_factor(
238            &table.relation,
239            ctx.dialect(),
240            &mut used_prefixes,
241        );
242
243        for join in &table.joins {
244            check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
245            collect_aliases(&join.relation, ctx.dialect(), &mut aliases);
246            collect_identifier_prefixes_from_table_factor(
247                &join.relation,
248                ctx.dialect(),
249                &mut used_prefixes,
250            );
251            if let Some(constraint) = join_constraint(&join.join_operator) {
252                collect_identifier_prefixes(constraint, ctx.dialect(), &mut used_prefixes);
253            }
254        }
255    }
256
257    if let Some(selection) = &delete.selection {
258        collect_identifier_prefixes(selection, ctx.dialect(), &mut used_prefixes);
259    }
260    if let Some(returning) = &delete.returning {
261        for item in returning {
262            collect_identifier_prefixes_from_select_item(item, ctx.dialect(), &mut used_prefixes);
263        }
264    }
265    for order_expr in &delete.order_by {
266        collect_identifier_prefixes(&order_expr.expr, ctx.dialect(), &mut used_prefixes);
267    }
268    if let Some(limit) = &delete.limit {
269        collect_identifier_prefixes(limit, ctx.dialect(), &mut used_prefixes);
270    }
271
272    emit_unused_alias_issues(
273        &aliases,
274        &used_prefixes,
275        alias_case_check,
276        ctx.dialect(),
277        ctx.statement_index,
278        issues,
279    );
280}
281
282fn delete_source_tables(delete: &Delete) -> Vec<&TableWithJoins> {
283    let mut tables = Vec::new();
284
285    match &delete.from {
286        FromTable::WithFromKeyword(from) | FromTable::WithoutKeyword(from) => {
287            tables.extend(from.iter());
288        }
289    }
290
291    if let Some(using_tables) = &delete.using {
292        tables.extend(using_tables.iter());
293    }
294
295    tables
296}
297
298fn emit_unused_alias_issues(
299    aliases: &HashMap<String, AliasRef>,
300    used_prefixes: &HashSet<QualifierRef>,
301    alias_case_check: AliasCaseCheck,
302    dialect: Dialect,
303    statement_index: usize,
304    issues: &mut Vec<Issue>,
305) {
306    if aliases.is_empty() {
307        return;
308    }
309
310    let mut used_alias_names: HashSet<String> = HashSet::new();
311    for alias in aliases.values() {
312        let used = used_prefixes
313            .iter()
314            .any(|prefix| qualifier_matches_alias(prefix, alias, alias_case_check, dialect));
315        if used {
316            used_alias_names.insert(alias.name.clone());
317        }
318    }
319
320    let mut relation_alias_counts: HashMap<String, usize> = HashMap::new();
321    let mut relations_with_used_alias: HashSet<String> = HashSet::new();
322    for alias in aliases.values() {
323        let Some(relation_key) = &alias.relation_key else {
324            continue;
325        };
326        *relation_alias_counts
327            .entry(relation_key.clone())
328            .or_insert(0) += 1;
329        if used_alias_names.contains(&alias.name) {
330            relations_with_used_alias.insert(relation_key.clone());
331        }
332    }
333
334    for alias in aliases.values() {
335        if used_alias_names.contains(&alias.name) {
336            continue;
337        }
338
339        let repeated_self_join_alias_exempt = alias.relation_key.as_ref().is_some_and(|key| {
340            relation_alias_counts.get(key).copied().unwrap_or_default() > 1
341                && relations_with_used_alias.contains(key)
342        });
343
344        if repeated_self_join_alias_exempt {
345            continue;
346        }
347
348        issues.push(
349            Issue::warning(
350                issue_codes::LINT_AL_005,
351                format!(
352                    "Table alias '{}' is defined but never referenced.",
353                    alias.name
354                ),
355            )
356            .with_statement(statement_index),
357        );
358    }
359}
360
361fn collect_identifier_prefixes_from_order_by(
362    order_by: &OrderBy,
363    dialect: Dialect,
364    prefixes: &mut HashSet<QualifierRef>,
365) {
366    if let OrderByKind::Expressions(order_by_exprs) = &order_by.kind {
367        for order_expr in order_by_exprs {
368            collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
369        }
370    }
371}
372
373fn collect_identifier_prefixes_from_query(
374    query: &Query,
375    dialect: Dialect,
376    prefixes: &mut HashSet<QualifierRef>,
377) {
378    if let Some(ref with) = query.with {
379        for cte in &with.cte_tables {
380            collect_identifier_prefixes_from_query(&cte.query, dialect, prefixes);
381        }
382    }
383
384    match query.body.as_ref() {
385        SetExpr::Select(select) => {
386            collect_identifier_prefixes_from_select(
387                select,
388                query.order_by.as_ref(),
389                dialect,
390                prefixes,
391            );
392        }
393        SetExpr::Query(q) => collect_identifier_prefixes_from_query(q, dialect, prefixes),
394        SetExpr::SetOperation { left, right, .. } => {
395            collect_identifier_prefixes_from_set_expr(left, dialect, prefixes);
396            collect_identifier_prefixes_from_set_expr(right, dialect, prefixes);
397        }
398        _ => {}
399    }
400}
401
402fn collect_identifier_prefixes_from_set_expr(
403    body: &SetExpr,
404    dialect: Dialect,
405    prefixes: &mut HashSet<QualifierRef>,
406) {
407    match body {
408        SetExpr::Select(select) => {
409            collect_identifier_prefixes_from_select(select, None, dialect, prefixes)
410        }
411        SetExpr::Query(q) => collect_identifier_prefixes_from_query(q, dialect, prefixes),
412        SetExpr::SetOperation { left, right, .. } => {
413            collect_identifier_prefixes_from_set_expr(left, dialect, prefixes);
414            collect_identifier_prefixes_from_set_expr(right, dialect, prefixes);
415        }
416        _ => {}
417    }
418}
419
420fn collect_identifier_prefixes_from_select(
421    select: &Select,
422    order_by: Option<&OrderBy>,
423    dialect: Dialect,
424    prefixes: &mut HashSet<QualifierRef>,
425) {
426    for item in &select.projection {
427        collect_identifier_prefixes_from_select_item(item, dialect, prefixes);
428    }
429    if let Some(ref prewhere) = select.prewhere {
430        collect_identifier_prefixes(prewhere, dialect, prefixes);
431    }
432    if let Some(ref selection) = select.selection {
433        collect_identifier_prefixes(selection, dialect, prefixes);
434    }
435    if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
436        for expr in exprs {
437            collect_identifier_prefixes(expr, dialect, prefixes);
438        }
439    }
440    for expr in &select.cluster_by {
441        collect_identifier_prefixes(expr, dialect, prefixes);
442    }
443    for expr in &select.distribute_by {
444        collect_identifier_prefixes(expr, dialect, prefixes);
445    }
446    for sort_expr in &select.sort_by {
447        collect_identifier_prefixes(&sort_expr.expr, dialect, prefixes);
448    }
449    if let Some(ref having) = select.having {
450        collect_identifier_prefixes(having, dialect, prefixes);
451    }
452    if let Some(ref qualify) = select.qualify {
453        if include_qualify_alias_references(dialect, select) {
454            collect_identifier_prefixes(qualify, dialect, prefixes);
455        }
456    }
457    if let Some(Distinct::On(exprs)) = &select.distinct {
458        for expr in exprs {
459            collect_identifier_prefixes(expr, dialect, prefixes);
460        }
461    }
462    for named_window in &select.named_window {
463        if let NamedWindowExpr::WindowSpec(spec) = &named_window.1 {
464            for expr in &spec.partition_by {
465                collect_identifier_prefixes(expr, dialect, prefixes);
466            }
467            for order_expr in &spec.order_by {
468                collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
469            }
470        }
471    }
472    for lateral_view in &select.lateral_views {
473        collect_identifier_prefixes(&lateral_view.lateral_view, dialect, prefixes);
474    }
475    for connect_by_kind in &select.connect_by {
476        match connect_by_kind {
477            ConnectByKind::ConnectBy { relationships, .. } => {
478                for relationship in relationships {
479                    collect_identifier_prefixes(relationship, dialect, prefixes);
480                }
481            }
482            ConnectByKind::StartWith { condition, .. } => {
483                collect_identifier_prefixes(condition, dialect, prefixes);
484            }
485        }
486    }
487    for from_item in &select.from {
488        collect_identifier_prefixes_from_table_factor(&from_item.relation, dialect, prefixes);
489        for join in &from_item.joins {
490            collect_identifier_prefixes_from_table_factor(&join.relation, dialect, prefixes);
491            if let Some(constraint) = join_constraint(&join.join_operator) {
492                collect_identifier_prefixes(constraint, dialect, prefixes);
493            }
494        }
495    }
496    if let Some(order_by) = order_by {
497        collect_identifier_prefixes_from_order_by(order_by, dialect, prefixes);
498    }
499}
500
501fn collect_aliases(
502    relation: &TableFactor,
503    dialect: Dialect,
504    aliases: &mut HashMap<String, AliasRef>,
505) {
506    match relation {
507        TableFactor::Table {
508            name,
509            alias: Some(alias),
510            args,
511            ..
512        } => {
513            if args.is_some() {
514                return;
515            }
516            if is_implicit_array_relation_alias(dialect, name, aliases) {
517                return;
518            }
519            let table_name = name.to_string();
520            let alias_name = alias.name.value.clone();
521            // Only count as alias if it differs from the table name.
522            if alias_name.to_uppercase() != table_name.to_uppercase() {
523                aliases.insert(
524                    alias_name.clone(),
525                    AliasRef {
526                        name: alias_name,
527                        quoted: alias.name.quote_style.is_some(),
528                        relation_key: Some(table_name.to_ascii_uppercase()),
529                    },
530                );
531            }
532        }
533        TableFactor::Derived {
534            subquery,
535            alias: Some(alias),
536            ..
537        } => {
538            if derived_values_alias_can_be_unused(dialect, subquery) {
539                aliases.insert(
540                    alias.name.value.clone(),
541                    AliasRef {
542                        name: alias.name.value.clone(),
543                        quoted: alias.name.quote_style.is_some(),
544                        relation_key: None,
545                    },
546                );
547            }
548        }
549        TableFactor::Function {
550            lateral: true,
551            alias: Some(alias),
552            ..
553        } => {
554            aliases.insert(
555                alias.name.value.clone(),
556                AliasRef {
557                    name: alias.name.value.clone(),
558                    quoted: alias.name.quote_style.is_some(),
559                    relation_key: None,
560                },
561            );
562        }
563        TableFactor::NestedJoin {
564            table_with_joins, ..
565        } => {
566            collect_aliases(&table_with_joins.relation, dialect, aliases);
567            for join in &table_with_joins.joins {
568                collect_aliases(&join.relation, dialect, aliases);
569            }
570        }
571        TableFactor::Pivot { table, .. }
572        | TableFactor::Unpivot { table, .. }
573        | TableFactor::MatchRecognize { table, .. } => collect_aliases(table, dialect, aliases),
574        _ => {}
575    }
576}
577
578fn collect_identifier_prefixes_from_select_item(
579    item: &SelectItem,
580    dialect: Dialect,
581    prefixes: &mut HashSet<QualifierRef>,
582) {
583    match item {
584        SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
585            collect_identifier_prefixes(expr, dialect, prefixes);
586        }
587        SelectItem::QualifiedWildcard(name, _) => {
588            let name_str = name.to_string();
589            if let Some(prefix) = name_str.split('.').next() {
590                prefixes.insert(QualifierRef {
591                    name: prefix
592                        .trim_matches(|ch| matches!(ch, '"' | '`' | '\'' | '[' | ']'))
593                        .to_string(),
594                    quoted: prefix.starts_with('"')
595                        || prefix.starts_with('`')
596                        || prefix.starts_with('['),
597                });
598            }
599        }
600        _ => {}
601    }
602}
603
604fn collect_identifier_prefixes(
605    expr: &Expr,
606    dialect: Dialect,
607    prefixes: &mut HashSet<QualifierRef>,
608) {
609    match expr {
610        Expr::CompoundIdentifier(parts) => {
611            if parts.len() >= 2 {
612                prefixes.insert(QualifierRef {
613                    name: parts[0].value.clone(),
614                    quoted: parts[0].quote_style.is_some(),
615                });
616            }
617        }
618        Expr::BinaryOp { left, right, .. } => {
619            collect_identifier_prefixes(left, dialect, prefixes);
620            collect_identifier_prefixes(right, dialect, prefixes);
621        }
622        Expr::UnaryOp { expr: inner, .. } => collect_identifier_prefixes(inner, dialect, prefixes),
623        Expr::Nested(inner) => collect_identifier_prefixes(inner, dialect, prefixes),
624        Expr::Function(func) => {
625            let function_name = function_name(func);
626            if let FunctionArguments::List(arg_list) = &func.args {
627                for (index, arg) in arg_list.args.iter().enumerate() {
628                    match arg {
629                        FunctionArg::Unnamed(FunctionArgExpr::Expr(e))
630                        | FunctionArg::Named {
631                            arg: FunctionArgExpr::Expr(e),
632                            ..
633                        } => {
634                            collect_identifier_prefixes(e, dialect, prefixes);
635                            if function_arg_is_table_alias_reference(
636                                dialect,
637                                function_name.as_str(),
638                                index,
639                            ) {
640                                if let Expr::Identifier(ident) = e {
641                                    prefixes.insert(QualifierRef {
642                                        name: ident.value.clone(),
643                                        quoted: ident.quote_style.is_some(),
644                                    });
645                                }
646                            }
647                        }
648                        _ => {}
649                    }
650                }
651            }
652            if let Some(filter) = &func.filter {
653                collect_identifier_prefixes(filter, dialect, prefixes);
654            }
655            for order_expr in &func.within_group {
656                collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
657            }
658            if let Some(WindowType::WindowSpec(spec)) = &func.over {
659                for expr in &spec.partition_by {
660                    collect_identifier_prefixes(expr, dialect, prefixes);
661                }
662                for order_expr in &spec.order_by {
663                    collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
664                }
665            }
666        }
667        Expr::IsNull(inner)
668        | Expr::IsNotNull(inner)
669        | Expr::Cast { expr: inner, .. }
670        | Expr::JsonAccess { value: inner, .. } => {
671            collect_identifier_prefixes(inner, dialect, prefixes);
672        }
673        Expr::Case {
674            operand,
675            conditions,
676            else_result,
677            ..
678        } => {
679            if let Some(op) = operand {
680                collect_identifier_prefixes(op, dialect, prefixes);
681            }
682            for case_when in conditions {
683                collect_identifier_prefixes(&case_when.condition, dialect, prefixes);
684                collect_identifier_prefixes(&case_when.result, dialect, prefixes);
685            }
686            if let Some(el) = else_result {
687                collect_identifier_prefixes(el, dialect, prefixes);
688            }
689        }
690        Expr::InList { expr, list, .. } => {
691            collect_identifier_prefixes(expr, dialect, prefixes);
692            for item in list {
693                collect_identifier_prefixes(item, dialect, prefixes);
694            }
695        }
696        Expr::InSubquery { expr, subquery, .. } => {
697            collect_identifier_prefixes(expr, dialect, prefixes);
698            collect_identifier_prefixes_from_query(subquery, dialect, prefixes);
699        }
700        Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
701            collect_identifier_prefixes(left, dialect, prefixes);
702            collect_identifier_prefixes(right, dialect, prefixes);
703        }
704        Expr::Subquery(subquery) | Expr::Exists { subquery, .. } => {
705            collect_identifier_prefixes_from_query(subquery, dialect, prefixes);
706        }
707        Expr::Between {
708            expr, low, high, ..
709        } => {
710            collect_identifier_prefixes(expr, dialect, prefixes);
711            collect_identifier_prefixes(low, dialect, prefixes);
712            collect_identifier_prefixes(high, dialect, prefixes);
713        }
714        _ => {}
715    }
716}
717
718fn collect_identifier_prefixes_from_table_factor(
719    table_factor: &TableFactor,
720    dialect: Dialect,
721    prefixes: &mut HashSet<QualifierRef>,
722) {
723    match table_factor {
724        TableFactor::Table { name, .. } => {
725            if let Some(prefix) = implicit_array_relation_prefix(dialect, name) {
726                prefixes.insert(prefix);
727            }
728        }
729        TableFactor::Derived {
730            lateral: true,
731            subquery,
732            ..
733        } => collect_identifier_prefixes_from_query(subquery, dialect, prefixes),
734        TableFactor::TableFunction { expr, .. } => {
735            collect_identifier_prefixes(expr, dialect, prefixes);
736        }
737        TableFactor::Function { args, .. } => {
738            for arg in args {
739                collect_identifier_prefixes_from_function_arg(arg, dialect, prefixes);
740            }
741        }
742        TableFactor::UNNEST { array_exprs, .. } => {
743            for expr in array_exprs {
744                collect_identifier_prefixes(expr, dialect, prefixes);
745            }
746        }
747        TableFactor::JsonTable { json_expr, .. } | TableFactor::OpenJsonTable { json_expr, .. } => {
748            collect_identifier_prefixes(json_expr, dialect, prefixes);
749        }
750        TableFactor::NestedJoin {
751            table_with_joins, ..
752        } => {
753            collect_identifier_prefixes_from_table_factor(
754                &table_with_joins.relation,
755                dialect,
756                prefixes,
757            );
758            for join in &table_with_joins.joins {
759                collect_identifier_prefixes_from_table_factor(&join.relation, dialect, prefixes);
760                if let Some(constraint) = join_constraint(&join.join_operator) {
761                    collect_identifier_prefixes(constraint, dialect, prefixes);
762                }
763            }
764        }
765        TableFactor::Pivot {
766            table,
767            aggregate_functions,
768            value_column,
769            default_on_null,
770            ..
771        } => {
772            collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
773            for expr_with_alias in aggregate_functions {
774                collect_identifier_prefixes(&expr_with_alias.expr, dialect, prefixes);
775            }
776            for expr in value_column {
777                collect_identifier_prefixes(expr, dialect, prefixes);
778            }
779            if let Some(expr) = default_on_null {
780                collect_identifier_prefixes(expr, dialect, prefixes);
781            }
782        }
783        TableFactor::Unpivot {
784            table,
785            value,
786            columns,
787            ..
788        } => {
789            collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
790            collect_identifier_prefixes(value, dialect, prefixes);
791            for expr_with_alias in columns {
792                collect_identifier_prefixes(&expr_with_alias.expr, dialect, prefixes);
793            }
794        }
795        TableFactor::MatchRecognize {
796            table,
797            partition_by,
798            order_by,
799            measures,
800            ..
801        } => {
802            collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
803            for expr in partition_by {
804                collect_identifier_prefixes(expr, dialect, prefixes);
805            }
806            for order in order_by {
807                collect_identifier_prefixes(&order.expr, dialect, prefixes);
808            }
809            for measure in measures {
810                collect_identifier_prefixes(&measure.expr, dialect, prefixes);
811            }
812        }
813        TableFactor::XmlTable { row_expression, .. } => {
814            collect_identifier_prefixes(row_expression, dialect, prefixes);
815        }
816        _ => {}
817    }
818}
819
820fn collect_identifier_prefixes_from_function_arg(
821    arg: &FunctionArg,
822    dialect: Dialect,
823    prefixes: &mut HashSet<QualifierRef>,
824) {
825    match arg {
826        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
827        | FunctionArg::Named {
828            arg: FunctionArgExpr::Expr(expr),
829            ..
830        } => collect_identifier_prefixes(expr, dialect, prefixes),
831        _ => {}
832    }
833}
834
835fn function_name(function: &Function) -> String {
836    function
837        .name
838        .0
839        .last()
840        .and_then(ObjectNamePart::as_ident)
841        .map(|ident| ident.value.to_ascii_uppercase())
842        .unwrap_or_default()
843}
844
845fn function_arg_is_table_alias_reference(
846    dialect: Dialect,
847    function_name: &str,
848    arg_index: usize,
849) -> bool {
850    matches!(dialect, Dialect::Bigquery) && arg_index == 0 && function_name == "TO_JSON_STRING"
851}
852
853fn include_qualify_alias_references(dialect: Dialect, select: &Select) -> bool {
854    // SQLFluff AL05 Redshift parity: QUALIFY references only count for alias usage
855    // when QUALIFY immediately follows the FROM/JOIN section (no WHERE clause).
856    !matches!(dialect, Dialect::Redshift) || select.selection.is_none()
857}
858
859fn derived_values_alias_can_be_unused(dialect: Dialect, subquery: &Query) -> bool {
860    // SQLFluff AL05 parity: this currently applies to SparkSQL fixtures
861    // (mapped to Databricks). Other dialect fixtures treat VALUES aliases as
862    // valid/required and should not be flagged.
863    matches!(dialect, Dialect::Databricks) && matches!(subquery.body.as_ref(), SetExpr::Values(_))
864}
865
866fn redshift_qualify_uses_alias_prefixed_identifier(expr: &Expr, alias: &str) -> bool {
867    match expr {
868        Expr::Identifier(identifier) => {
869            let value = identifier.value.as_str();
870            value
871                .strip_prefix(alias)
872                .is_some_and(|suffix| suffix.starts_with('_'))
873                || value
874                    .to_ascii_uppercase()
875                    .strip_prefix(&alias.to_ascii_uppercase())
876                    .is_some_and(|suffix| suffix.starts_with('_'))
877        }
878        Expr::CompoundIdentifier(_) => false,
879        Expr::BinaryOp { left, right, .. }
880        | Expr::AnyOp { left, right, .. }
881        | Expr::AllOp { left, right, .. } => {
882            redshift_qualify_uses_alias_prefixed_identifier(left, alias)
883                || redshift_qualify_uses_alias_prefixed_identifier(right, alias)
884        }
885        Expr::UnaryOp { expr: inner, .. }
886        | Expr::Nested(inner)
887        | Expr::IsNull(inner)
888        | Expr::IsNotNull(inner)
889        | Expr::Cast { expr: inner, .. } => {
890            redshift_qualify_uses_alias_prefixed_identifier(inner, alias)
891        }
892        Expr::InList { expr, list, .. } => {
893            redshift_qualify_uses_alias_prefixed_identifier(expr, alias)
894                || list
895                    .iter()
896                    .any(|item| redshift_qualify_uses_alias_prefixed_identifier(item, alias))
897        }
898        Expr::Between {
899            expr, low, high, ..
900        } => {
901            redshift_qualify_uses_alias_prefixed_identifier(expr, alias)
902                || redshift_qualify_uses_alias_prefixed_identifier(low, alias)
903                || redshift_qualify_uses_alias_prefixed_identifier(high, alias)
904        }
905        Expr::Case {
906            operand,
907            conditions,
908            else_result,
909            ..
910        } => {
911            operand
912                .as_ref()
913                .is_some_and(|inner| redshift_qualify_uses_alias_prefixed_identifier(inner, alias))
914                || conditions.iter().any(|when| {
915                    redshift_qualify_uses_alias_prefixed_identifier(&when.condition, alias)
916                        || redshift_qualify_uses_alias_prefixed_identifier(&when.result, alias)
917                })
918                || else_result.as_ref().is_some_and(|inner| {
919                    redshift_qualify_uses_alias_prefixed_identifier(inner, alias)
920                })
921        }
922        Expr::Function(function) => {
923            let args_match = if let FunctionArguments::List(arguments) = &function.args {
924                arguments.args.iter().any(|arg| match arg {
925                    FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
926                    | FunctionArg::Named {
927                        arg: FunctionArgExpr::Expr(expr),
928                        ..
929                    } => redshift_qualify_uses_alias_prefixed_identifier(expr, alias),
930                    _ => false,
931                })
932            } else {
933                false
934            };
935            let filter_match = function.filter.as_ref().is_some_and(|filter| {
936                redshift_qualify_uses_alias_prefixed_identifier(filter, alias)
937            });
938            let within_group_match = function.within_group.iter().any(|order_expr| {
939                redshift_qualify_uses_alias_prefixed_identifier(&order_expr.expr, alias)
940            });
941            let over_match = match &function.over {
942                Some(WindowType::WindowSpec(spec)) => {
943                    spec.partition_by
944                        .iter()
945                        .any(|expr| redshift_qualify_uses_alias_prefixed_identifier(expr, alias))
946                        || spec.order_by.iter().any(|order_expr| {
947                            redshift_qualify_uses_alias_prefixed_identifier(&order_expr.expr, alias)
948                        })
949                }
950                _ => false,
951            };
952            args_match || filter_match || within_group_match || over_match
953        }
954        _ => false,
955    }
956}
957
958fn implicit_array_relation_prefix(dialect: Dialect, name: &ObjectName) -> Option<QualifierRef> {
959    if !matches!(dialect, Dialect::Bigquery | Dialect::Redshift) {
960        return None;
961    }
962    if name.0.len() != 2 {
963        return None;
964    }
965    let first = name.0.first()?.as_ident()?;
966    Some(QualifierRef {
967        name: first.value.clone(),
968        quoted: first.quote_style.is_some(),
969    })
970}
971
972fn is_implicit_array_relation_alias(
973    dialect: Dialect,
974    name: &ObjectName,
975    aliases: &HashMap<String, AliasRef>,
976) -> bool {
977    let Some(prefix) = implicit_array_relation_prefix(dialect, name) else {
978        return false;
979    };
980    aliases
981        .values()
982        .any(|alias| alias.name.eq_ignore_ascii_case(&prefix.name))
983}
984
985fn qualifier_matches_alias(
986    qualifier: &QualifierRef,
987    alias: &AliasRef,
988    alias_case_check: AliasCaseCheck,
989    dialect: Dialect,
990) -> bool {
991    match alias_case_check {
992        AliasCaseCheck::CaseInsensitive => qualifier.name.eq_ignore_ascii_case(&alias.name),
993        AliasCaseCheck::CaseSensitive => qualifier.name == alias.name,
994        AliasCaseCheck::Dialect => {
995            normalize_identifier_for_dialect(&qualifier.name, qualifier.quoted, dialect)
996                == normalize_identifier_for_dialect(&alias.name, alias.quoted, dialect)
997        }
998        AliasCaseCheck::QuotedCsNakedUpper => {
999            normalize_case_for_mode(qualifier, alias_case_check)
1000                == normalize_case_for_mode_alias(alias, alias_case_check)
1001        }
1002        AliasCaseCheck::QuotedCsNakedLower => {
1003            normalize_case_for_mode(qualifier, alias_case_check)
1004                == normalize_case_for_mode_alias(alias, alias_case_check)
1005        }
1006    }
1007}
1008
1009fn normalize_identifier_for_dialect(identifier: &str, quoted: bool, dialect: Dialect) -> String {
1010    if quoted && !quoted_identifiers_case_insensitive_for_dialect(dialect) {
1011        identifier.to_string()
1012    } else {
1013        normalize_naked_identifier_for_dialect(identifier, dialect)
1014    }
1015}
1016
1017fn normalize_naked_identifier_for_dialect(identifier: &str, dialect: Dialect) -> String {
1018    if matches!(
1019        dialect,
1020        Dialect::Postgres
1021            | Dialect::Redshift
1022            | Dialect::Mysql
1023            | Dialect::Sqlite
1024            | Dialect::Mssql
1025            | Dialect::Clickhouse
1026    ) {
1027        identifier.to_ascii_lowercase()
1028    } else {
1029        identifier.to_ascii_uppercase()
1030    }
1031}
1032
1033fn quoted_identifiers_case_insensitive_for_dialect(dialect: Dialect) -> bool {
1034    matches!(
1035        dialect,
1036        Dialect::Duckdb | Dialect::Hive | Dialect::Sqlite | Dialect::Databricks
1037    )
1038}
1039
1040fn normalize_case_for_mode(reference: &QualifierRef, mode: AliasCaseCheck) -> String {
1041    match mode {
1042        AliasCaseCheck::QuotedCsNakedUpper => {
1043            if reference.quoted {
1044                reference.name.clone()
1045            } else {
1046                reference.name.to_ascii_uppercase()
1047            }
1048        }
1049        AliasCaseCheck::QuotedCsNakedLower => {
1050            if reference.quoted {
1051                reference.name.clone()
1052            } else {
1053                reference.name.to_ascii_lowercase()
1054            }
1055        }
1056        _ => reference.name.clone(),
1057    }
1058}
1059
1060fn normalize_case_for_mode_alias(alias: &AliasRef, mode: AliasCaseCheck) -> String {
1061    match mode {
1062        AliasCaseCheck::QuotedCsNakedUpper => {
1063            if alias.quoted {
1064                alias.name.clone()
1065            } else {
1066                alias.name.to_ascii_uppercase()
1067            }
1068        }
1069        AliasCaseCheck::QuotedCsNakedLower => {
1070            if alias.quoted {
1071                alias.name.clone()
1072            } else {
1073                alias.name.to_ascii_lowercase()
1074            }
1075        }
1076        _ => alias.name.clone(),
1077    }
1078}
1079
1080fn join_constraint(op: &JoinOperator) -> Option<&Expr> {
1081    let constraint = match op {
1082        JoinOperator::Join(c)
1083        | JoinOperator::Left(c)
1084        | JoinOperator::Inner(c)
1085        | JoinOperator::Right(c)
1086        | JoinOperator::LeftOuter(c)
1087        | JoinOperator::RightOuter(c)
1088        | JoinOperator::FullOuter(c)
1089        | JoinOperator::LeftSemi(c)
1090        | JoinOperator::RightSemi(c)
1091        | JoinOperator::LeftAnti(c)
1092        | JoinOperator::RightAnti(c) => c,
1093        _ => return None,
1094    };
1095    match constraint {
1096        JoinConstraint::On(expr) => Some(expr),
1097        _ => None,
1098    }
1099}
1100
1101fn check_table_factor_subqueries(
1102    relation: &TableFactor,
1103    alias_case_check: AliasCaseCheck,
1104    ctx: &LintContext,
1105    issues: &mut Vec<Issue>,
1106) {
1107    match relation {
1108        TableFactor::Derived { subquery, .. } => {
1109            check_query(subquery, alias_case_check, ctx, issues);
1110        }
1111        TableFactor::NestedJoin {
1112            table_with_joins, ..
1113        } => {
1114            check_table_factor_subqueries(
1115                &table_with_joins.relation,
1116                alias_case_check,
1117                ctx,
1118                issues,
1119            );
1120            for join in &table_with_joins.joins {
1121                check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
1122            }
1123        }
1124        TableFactor::Pivot { table, .. }
1125        | TableFactor::Unpivot { table, .. }
1126        | TableFactor::MatchRecognize { table, .. } => {
1127            check_table_factor_subqueries(table, alias_case_check, ctx, issues);
1128        }
1129        _ => {}
1130    }
1131}
1132
1133#[derive(Debug, Clone)]
1134struct LegacySimpleTableAliasDecl {
1135    table_end: usize,
1136    alias_end: usize,
1137    alias: String,
1138    quoted: bool,
1139}
1140
1141#[derive(Clone)]
1142struct LegacyLocatedToken {
1143    token: Token,
1144    end: usize,
1145}
1146
1147fn al005_legacy_autofix_edits(
1148    sql: &str,
1149    dialect: Dialect,
1150    alias_case_check: AliasCaseCheck,
1151) -> Vec<(usize, usize)> {
1152    let Some(decls) = legacy_collect_simple_table_alias_declarations(sql, dialect) else {
1153        return Vec::new();
1154    };
1155    if decls.is_empty() {
1156        return Vec::new();
1157    }
1158
1159    let mut seen_aliases = HashSet::new();
1160    let mut removals = Vec::new();
1161    for decl in &decls {
1162        let alias_key = decl.alias.to_ascii_lowercase();
1163        if !seen_aliases.insert(alias_key.clone()) {
1164            continue;
1165        }
1166        if legacy_is_sql_keyword(&decl.alias) || legacy_is_generated_alias_identifier(&decl.alias) {
1167            continue;
1168        }
1169        if legacy_contains_alias_qualifier_dialect(
1170            sql,
1171            &decl.alias,
1172            decl.quoted,
1173            dialect,
1174            alias_case_check,
1175        ) {
1176            continue;
1177        }
1178
1179        removals.extend(
1180            decls
1181                .iter()
1182                .filter(|candidate| candidate.alias.eq_ignore_ascii_case(&alias_key))
1183                .map(|candidate| (candidate.table_end, candidate.alias_end)),
1184        );
1185    }
1186
1187    removals.sort_unstable();
1188    removals.dedup();
1189    removals.retain(|(start, end)| start < end);
1190    removals
1191}
1192
1193fn legacy_collect_simple_table_alias_declarations(
1194    sql: &str,
1195    dialect: Dialect,
1196) -> Option<Vec<LegacySimpleTableAliasDecl>> {
1197    let tokens = legacy_tokenize_with_offsets(sql, dialect)?;
1198    let mut out = Vec::new();
1199    let mut index = 0usize;
1200
1201    while index < tokens.len() {
1202        if !legacy_token_matches_keyword(&tokens[index].token, "FROM")
1203            && !legacy_token_matches_keyword(&tokens[index].token, "JOIN")
1204        {
1205            index += 1;
1206            continue;
1207        }
1208
1209        // Parse first table item after FROM/JOIN.
1210        let Some(next) = legacy_next_non_trivia_token(&tokens, index + 1) else {
1211            index += 1;
1212            continue;
1213        };
1214        index = legacy_try_parse_table_item(&tokens, next, dialect, &mut out);
1215
1216        // Handle comma-separated table items (FROM t1, t2, LATERAL f(...) AS x).
1217        loop {
1218            let Some(comma_index) = legacy_next_non_trivia_token(&tokens, index) else {
1219                break;
1220            };
1221            if !matches!(tokens[comma_index].token, Token::Comma) {
1222                break;
1223            }
1224            let Some(next_item) = legacy_next_non_trivia_token(&tokens, comma_index + 1) else {
1225                index = comma_index + 1;
1226                break;
1227            };
1228            index = legacy_try_parse_table_item(&tokens, next_item, dialect, &mut out);
1229        }
1230    }
1231
1232    Some(out)
1233}
1234
1235/// Try to parse a single table item (table reference or LATERAL function call) starting
1236/// at token `start`. Returns the next token index to continue scanning from.
1237fn legacy_try_parse_table_item(
1238    tokens: &[LegacyLocatedToken],
1239    start: usize,
1240    dialect: Dialect,
1241    out: &mut Vec<LegacySimpleTableAliasDecl>,
1242) -> usize {
1243    if start >= tokens.len() {
1244        return start;
1245    }
1246
1247    // Handle LATERAL function(...) AS alias pattern.
1248    if legacy_token_matches_keyword(&tokens[start].token, "LATERAL") {
1249        if let Some(func_end) = legacy_skip_lateral_function_call(tokens, start + 1) {
1250            let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, func_end) else {
1251                return func_end;
1252            };
1253            if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
1254                let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
1255                    return alias_index + 1;
1256                };
1257                alias_index = next_index;
1258            }
1259            if let Some((alias_value, alias_quoted)) =
1260                legacy_token_any_identifier(&tokens[alias_index].token)
1261            {
1262                out.push(LegacySimpleTableAliasDecl {
1263                    table_end: tokens[func_end - 1].end,
1264                    alias_end: tokens[alias_index].end,
1265                    alias: alias_value.to_string(),
1266                    quoted: alias_quoted,
1267                });
1268                return alias_index + 1;
1269            }
1270            return func_end;
1271        }
1272        return start + 1;
1273    }
1274
1275    // Handle parenthesized VALUES relation:
1276    //   (VALUES (...), (...)) AS t(c1, c2)
1277    if matches!(tokens[start].token, Token::LParen)
1278        && matches!(dialect, Dialect::Databricks)
1279        && legacy_parenthesized_relation_starts_with_values(tokens, start)
1280    {
1281        let Some(paren_end) = legacy_skip_parenthesized(tokens, start) else {
1282            return start + 1;
1283        };
1284        let table_end = tokens[paren_end - 1].end;
1285
1286        let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, paren_end) else {
1287            return paren_end;
1288        };
1289        if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
1290            let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
1291                return alias_index + 1;
1292            };
1293            alias_index = next_index;
1294        }
1295        let Some((alias_value, alias_quoted)) =
1296            legacy_token_any_identifier(&tokens[alias_index].token)
1297        else {
1298            return paren_end;
1299        };
1300
1301        let mut alias_end = tokens[alias_index].end;
1302        let mut next_cursor = alias_index + 1;
1303        if let Some(cols_start) = legacy_next_non_trivia_token(tokens, alias_index + 1) {
1304            if matches!(tokens[cols_start].token, Token::LParen) {
1305                if let Some(cols_end) = legacy_skip_parenthesized(tokens, cols_start) {
1306                    alias_end = tokens[cols_end - 1].end;
1307                    next_cursor = cols_end;
1308                }
1309            }
1310        }
1311
1312        out.push(LegacySimpleTableAliasDecl {
1313            table_end,
1314            alias_end,
1315            alias: alias_value.to_string(),
1316            quoted: alias_quoted,
1317        });
1318        return next_cursor;
1319    }
1320
1321    // Table name: identifier(.identifier)*
1322    if legacy_token_any_identifier(&tokens[start].token).is_none() {
1323        return start + 1;
1324    }
1325
1326    let mut table_end = tokens[start].end;
1327    let mut cursor = start + 1;
1328
1329    loop {
1330        let Some(dot_index) = legacy_next_non_trivia_token(tokens, cursor) else {
1331            break;
1332        };
1333        if !matches!(tokens[dot_index].token, Token::Period) {
1334            break;
1335        }
1336        let Some(next_index) = legacy_next_non_trivia_token(tokens, dot_index + 1) else {
1337            break;
1338        };
1339        if legacy_token_any_identifier(&tokens[next_index].token).is_none() {
1340            break;
1341        }
1342        table_end = tokens[next_index].end;
1343        cursor = next_index + 1;
1344    }
1345
1346    let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, cursor) else {
1347        return cursor;
1348    };
1349    if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
1350        let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
1351            return alias_index + 1;
1352        };
1353        alias_index = next_index;
1354    }
1355
1356    let Some((alias_value, alias_quoted)) = legacy_token_any_identifier(&tokens[alias_index].token)
1357    else {
1358        return cursor;
1359    };
1360
1361    out.push(LegacySimpleTableAliasDecl {
1362        table_end,
1363        alias_end: tokens[alias_index].end,
1364        alias: alias_value.to_string(),
1365        quoted: alias_quoted,
1366    });
1367    alias_index + 1
1368}
1369
1370fn legacy_tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LegacyLocatedToken>> {
1371    let dialect = dialect.to_sqlparser_dialect();
1372    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
1373    let tokens = tokenizer.tokenize_with_location().ok()?;
1374
1375    let mut out = Vec::with_capacity(tokens.len());
1376    for token in tokens {
1377        let (_, end) = legacy_token_with_span_offsets(sql, &token)?;
1378        out.push(LegacyLocatedToken {
1379            token: token.token,
1380            end,
1381        });
1382    }
1383    Some(out)
1384}
1385
1386fn legacy_token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
1387    let start = legacy_line_col_to_offset(
1388        sql,
1389        token.span.start.line as usize,
1390        token.span.start.column as usize,
1391    )?;
1392    let end = legacy_line_col_to_offset(
1393        sql,
1394        token.span.end.line as usize,
1395        token.span.end.column as usize,
1396    )?;
1397    Some((start, end))
1398}
1399
1400fn legacy_line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
1401    if line == 0 || column == 0 {
1402        return None;
1403    }
1404
1405    let mut current_line = 1usize;
1406    let mut current_col = 1usize;
1407
1408    for (offset, ch) in sql.char_indices() {
1409        if current_line == line && current_col == column {
1410            return Some(offset);
1411        }
1412
1413        if ch == '\n' {
1414            current_line += 1;
1415            current_col = 1;
1416        } else {
1417            current_col += 1;
1418        }
1419    }
1420
1421    if current_line == line && current_col == column {
1422        return Some(sql.len());
1423    }
1424
1425    None
1426}
1427
1428fn legacy_next_non_trivia_token(tokens: &[LegacyLocatedToken], mut start: usize) -> Option<usize> {
1429    while start < tokens.len() {
1430        if !legacy_is_trivia_token(&tokens[start].token) {
1431            return Some(start);
1432        }
1433        start += 1;
1434    }
1435    None
1436}
1437
1438/// Skip past `FUNCTION_NAME(...)` after LATERAL keyword.
1439/// Returns the token index right after the closing `)`, or None if not found.
1440fn legacy_skip_lateral_function_call(tokens: &[LegacyLocatedToken], start: usize) -> Option<usize> {
1441    // Expect: function_name ( ... )
1442    let func_index = legacy_next_non_trivia_token(tokens, start)?;
1443    legacy_token_any_identifier(&tokens[func_index].token)?;
1444    let lparen_index = legacy_next_non_trivia_token(tokens, func_index + 1)?;
1445    if !matches!(tokens[lparen_index].token, Token::LParen) {
1446        return None;
1447    }
1448    // Find matching closing paren, handling nesting.
1449    let mut depth = 1u32;
1450    let mut cursor = lparen_index + 1;
1451    while cursor < tokens.len() && depth > 0 {
1452        match &tokens[cursor].token {
1453            Token::LParen => depth += 1,
1454            Token::RParen => depth -= 1,
1455            _ => {}
1456        }
1457        cursor += 1;
1458    }
1459    if depth == 0 {
1460        Some(cursor)
1461    } else {
1462        None
1463    }
1464}
1465
1466fn legacy_parenthesized_relation_starts_with_values(
1467    tokens: &[LegacyLocatedToken],
1468    lparen_index: usize,
1469) -> bool {
1470    let Some(first_inner) = legacy_next_non_trivia_token(tokens, lparen_index + 1) else {
1471        return false;
1472    };
1473    legacy_token_matches_keyword(&tokens[first_inner].token, "VALUES")
1474}
1475
1476fn legacy_skip_parenthesized(tokens: &[LegacyLocatedToken], lparen_index: usize) -> Option<usize> {
1477    if !matches!(tokens.get(lparen_index)?.token, Token::LParen) {
1478        return None;
1479    }
1480    let mut depth = 1u32;
1481    let mut cursor = lparen_index + 1;
1482    while cursor < tokens.len() && depth > 0 {
1483        match &tokens[cursor].token {
1484            Token::LParen => depth += 1,
1485            Token::RParen => depth -= 1,
1486            _ => {}
1487        }
1488        cursor += 1;
1489    }
1490    if depth == 0 {
1491        Some(cursor)
1492    } else {
1493        None
1494    }
1495}
1496
1497fn legacy_is_trivia_token(token: &Token) -> bool {
1498    matches!(
1499        token,
1500        Token::Whitespace(
1501            Whitespace::Space
1502                | Whitespace::Newline
1503                | Whitespace::Tab
1504                | Whitespace::SingleLineComment { .. }
1505                | Whitespace::MultiLineComment(_)
1506        )
1507    )
1508}
1509
1510fn legacy_token_matches_keyword(token: &Token, keyword: &str) -> bool {
1511    matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(keyword))
1512}
1513
1514/// Extract identifier value from any token type (unquoted, single-quoted,
1515/// double-quoted, backtick-quoted). Returns (value, is_quoted).
1516fn legacy_token_any_identifier(token: &Token) -> Option<(&str, bool)> {
1517    match token {
1518        Token::Word(word) if legacy_is_simple_identifier(&word.value) => {
1519            if word.quote_style.is_some() {
1520                Some((&word.value, true))
1521            } else {
1522                Some((&word.value, false))
1523            }
1524        }
1525        Token::SingleQuotedString(s) => Some((s.as_str(), true)),
1526        _ => None,
1527    }
1528}
1529
1530fn legacy_contains_alias_qualifier_dialect(
1531    sql: &str,
1532    alias: &str,
1533    alias_quoted: bool,
1534    dialect: Dialect,
1535    alias_case_check: AliasCaseCheck,
1536) -> bool {
1537    if matches!(dialect, Dialect::Redshift)
1538        && legacy_redshift_qualify_uses_alias_prefixed_identifier(sql, alias)
1539    {
1540        return true;
1541    }
1542
1543    let alias_bytes = alias.as_bytes();
1544    if alias_bytes.is_empty() {
1545        return false;
1546    }
1547
1548    // Build the normalized alias for comparison, respecting the dialect/config.
1549    let normalized_alias = match alias_case_check {
1550        AliasCaseCheck::Dialect => normalize_identifier_for_dialect(alias, alias_quoted, dialect),
1551        AliasCaseCheck::CaseInsensitive => alias.to_ascii_lowercase(),
1552        AliasCaseCheck::CaseSensitive => alias.to_string(),
1553        AliasCaseCheck::QuotedCsNakedUpper => {
1554            if alias_quoted {
1555                alias.to_string()
1556            } else {
1557                alias.to_ascii_uppercase()
1558            }
1559        }
1560        AliasCaseCheck::QuotedCsNakedLower => {
1561            if alias_quoted {
1562                alias.to_string()
1563            } else {
1564                alias.to_ascii_lowercase()
1565            }
1566        }
1567    };
1568
1569    let bytes = sql.as_bytes();
1570    let mut index = 0usize;
1571    while index < bytes.len() {
1572        // Skip past quote characters to handle quoted qualifiers like "A".col
1573        let (ref_name, ref_quoted, ref_end) = if index < bytes.len()
1574            && (bytes[index] == b'"' || bytes[index] == b'`' || bytes[index] == b'[')
1575        {
1576            let close_char = match bytes[index] {
1577                b'"' => b'"',
1578                b'`' => b'`',
1579                b'[' => b']',
1580                _ => unreachable!(),
1581            };
1582            let start = index + 1;
1583            let mut end = start;
1584            while end < bytes.len() && bytes[end] != close_char {
1585                end += 1;
1586            }
1587            if end >= bytes.len() {
1588                index += 1;
1589                continue;
1590            }
1591            let name = &sql[start..end];
1592            // end points to the closing quote; advance past it
1593            (name.to_string(), true, end + 1)
1594        } else if index < bytes.len() && legacy_is_ascii_ident_start(bytes[index]) {
1595            let start = index;
1596            let mut end = start;
1597            while end < bytes.len() && legacy_is_ascii_ident_continue(bytes[end]) {
1598                end += 1;
1599            }
1600            let name = &sql[start..end];
1601            (name.to_string(), false, end)
1602        } else {
1603            index += 1;
1604            continue;
1605        };
1606
1607        // Check if followed by '.'
1608        if ref_end < bytes.len() && bytes[ref_end] == b'.' {
1609            let normalized_ref = match alias_case_check {
1610                AliasCaseCheck::Dialect => {
1611                    normalize_identifier_for_dialect(&ref_name, ref_quoted, dialect)
1612                }
1613                AliasCaseCheck::CaseInsensitive => ref_name.to_ascii_lowercase(),
1614                AliasCaseCheck::CaseSensitive => ref_name.clone(),
1615                AliasCaseCheck::QuotedCsNakedUpper => {
1616                    if ref_quoted {
1617                        ref_name.clone()
1618                    } else {
1619                        ref_name.to_ascii_uppercase()
1620                    }
1621                }
1622                AliasCaseCheck::QuotedCsNakedLower => {
1623                    if ref_quoted {
1624                        ref_name.clone()
1625                    } else {
1626                        ref_name.to_ascii_lowercase()
1627                    }
1628                }
1629            };
1630            if normalized_ref == normalized_alias {
1631                return true;
1632            }
1633        }
1634
1635        index = if ref_end > index { ref_end } else { index + 1 };
1636    }
1637
1638    false
1639}
1640
1641fn legacy_redshift_qualify_uses_alias_prefixed_identifier(sql: &str, alias: &str) -> bool {
1642    let Some(tokens) = legacy_tokenize_with_offsets(sql, Dialect::Redshift) else {
1643        return false;
1644    };
1645    let Some(qualify_index) = tokens
1646        .iter()
1647        .position(|token| legacy_token_matches_keyword(&token.token, "QUALIFY"))
1648    else {
1649        return false;
1650    };
1651
1652    // SQLFluff parity: for Redshift AL05, QUALIFY references only count when
1653    // QUALIFY follows FROM/JOIN directly (i.e. no WHERE before QUALIFY).
1654    if tokens[..qualify_index]
1655        .iter()
1656        .any(|token| legacy_token_matches_keyword(&token.token, "WHERE"))
1657    {
1658        return false;
1659    }
1660
1661    tokens[qualify_index + 1..]
1662        .iter()
1663        .filter_map(|token| legacy_token_reference_identifier(&token.token))
1664        .any(|identifier| legacy_alias_prefixed_identifier(identifier, alias))
1665}
1666
1667fn legacy_token_reference_identifier(token: &Token) -> Option<&str> {
1668    match token {
1669        Token::Word(word) => Some(word.value.as_str()),
1670        _ => None,
1671    }
1672}
1673
1674fn legacy_alias_prefixed_identifier(identifier: &str, alias: &str) -> bool {
1675    if identifier.is_empty() || alias.is_empty() {
1676        return false;
1677    }
1678    identifier
1679        .to_ascii_uppercase()
1680        .strip_prefix(&alias.to_ascii_uppercase())
1681        .is_some_and(|suffix| suffix.starts_with('_'))
1682}
1683
1684fn legacy_is_generated_alias_identifier(alias: &str) -> bool {
1685    let mut chars = alias.chars();
1686    match chars.next() {
1687        Some('t') => {}
1688        _ => return false,
1689    }
1690    let mut saw_digit = false;
1691    for ch in chars {
1692        if !ch.is_ascii_digit() {
1693            return false;
1694        }
1695        saw_digit = true;
1696    }
1697    saw_digit
1698}
1699
1700fn legacy_is_sql_keyword(token: &str) -> bool {
1701    matches!(
1702        token.to_ascii_uppercase().as_str(),
1703        "ALL"
1704            | "ALTER"
1705            | "AND"
1706            | "ANY"
1707            | "AS"
1708            | "ASC"
1709            | "BEGIN"
1710            | "BETWEEN"
1711            | "BOOLEAN"
1712            | "BY"
1713            | "CASE"
1714            | "CAST"
1715            | "CHECK"
1716            | "COLUMN"
1717            | "CONSTRAINT"
1718            | "CREATE"
1719            | "CROSS"
1720            | "DEFAULT"
1721            | "DELETE"
1722            | "DESC"
1723            | "DISTINCT"
1724            | "DROP"
1725            | "ELSE"
1726            | "END"
1727            | "EXCEPT"
1728            | "EXISTS"
1729            | "FALSE"
1730            | "FETCH"
1731            | "FOR"
1732            | "FOREIGN"
1733            | "FROM"
1734            | "FULL"
1735            | "GROUP"
1736            | "HAVING"
1737            | "IF"
1738            | "IN"
1739            | "INDEX"
1740            | "INNER"
1741            | "INSERT"
1742            | "INT"
1743            | "INTEGER"
1744            | "INTERSECT"
1745            | "INTO"
1746            | "IS"
1747            | "JOIN"
1748            | "KEY"
1749            | "LEFT"
1750            | "LIKE"
1751            | "LIMIT"
1752            | "NOT"
1753            | "NULL"
1754            | "OFFSET"
1755            | "ON"
1756            | "OR"
1757            | "ORDER"
1758            | "OUTER"
1759            | "OVER"
1760            | "PARTITION"
1761            | "PRIMARY"
1762            | "REFERENCES"
1763            | "RIGHT"
1764            | "SELECT"
1765            | "SET"
1766            | "TABLE"
1767            | "TEXT"
1768            | "THEN"
1769            | "TRUE"
1770            | "UNION"
1771            | "UNIQUE"
1772            | "UPDATE"
1773            | "USING"
1774            | "VALUES"
1775            | "VARCHAR"
1776            | "VIEW"
1777            | "WHEN"
1778            | "WHERE"
1779            | "WINDOW"
1780            | "WITH"
1781    )
1782}
1783
1784fn legacy_is_simple_identifier(value: &str) -> bool {
1785    let bytes = value.as_bytes();
1786    if bytes.is_empty() || !legacy_is_ascii_ident_start(bytes[0]) {
1787        return false;
1788    }
1789    bytes[1..]
1790        .iter()
1791        .copied()
1792        .all(legacy_is_ascii_ident_continue)
1793}
1794
1795fn legacy_is_ascii_ident_start(byte: u8) -> bool {
1796    byte.is_ascii_alphabetic() || byte == b'_' || byte == b'#'
1797}
1798
1799fn legacy_is_ascii_ident_continue(byte: u8) -> bool {
1800    byte.is_ascii_alphanumeric() || byte == b'_'
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806    use crate::linter::config::LintConfig;
1807    use crate::linter::rule::with_active_dialect;
1808    use crate::parser::{parse_sql, parse_sql_with_dialect};
1809    use crate::types::{Dialect, IssueAutofixApplicability};
1810
1811    fn check_sql(sql: &str) -> Vec<Issue> {
1812        let stmts = parse_sql(sql).unwrap();
1813        let rule = UnusedTableAlias::default();
1814        let ctx = LintContext {
1815            sql,
1816            statement_range: 0..sql.len(),
1817            statement_index: 0,
1818        };
1819        let mut issues = Vec::new();
1820        for stmt in &stmts {
1821            issues.extend(rule.check(stmt, &ctx));
1822        }
1823        issues
1824    }
1825
1826    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
1827        let autofix = issue.autofix.as_ref()?;
1828        let mut edits = autofix.edits.clone();
1829        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
1830        let mut rewritten = sql.to_string();
1831        for edit in edits.into_iter().rev() {
1832            rewritten.replace_range(edit.span.start..edit.span.end, &edit.replacement);
1833        }
1834        Some(rewritten)
1835    }
1836
1837    fn check_sql_in_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
1838        let stmts = parse_sql_with_dialect(sql, dialect).unwrap();
1839        let rule = UnusedTableAlias::default();
1840        let ctx = LintContext {
1841            sql,
1842            statement_range: 0..sql.len(),
1843            statement_index: 0,
1844        };
1845        let mut issues = Vec::new();
1846        with_active_dialect(dialect, || {
1847            for stmt in &stmts {
1848                issues.extend(rule.check(stmt, &ctx));
1849            }
1850        });
1851        issues
1852    }
1853
1854    #[test]
1855    fn test_unused_alias_detected() {
1856        let issues = check_sql("SELECT * FROM users u JOIN orders o ON users.id = orders.user_id");
1857        // Both aliases u and o are unused (full table names used instead)
1858        assert_eq!(issues.len(), 2);
1859        assert_eq!(issues[0].code, "LINT_AL_005");
1860    }
1861
1862    #[test]
1863    fn test_unused_alias_emits_safe_autofix_patch() {
1864        let sql = "SELECT users.name FROM users AS u JOIN orders AS o ON users.id = orders.user_id";
1865        let issues = check_sql(sql);
1866        assert_eq!(issues.len(), 2);
1867        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
1868        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1869        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
1870        assert_eq!(
1871            fixed,
1872            "SELECT users.name FROM users JOIN orders ON users.id = orders.user_id"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_generated_alias_does_not_emit_autofix() {
1878        let sql = "SELECT * FROM users AS t1";
1879        let issues = check_sql(sql);
1880        assert_eq!(issues.len(), 1);
1881        assert!(
1882            issues[0].autofix.is_none(),
1883            "legacy AL005 parity skips generated aliases like t1"
1884        );
1885    }
1886
1887    #[test]
1888    fn test_used_alias_ok() {
1889        let issues = check_sql("SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id");
1890        assert!(issues.is_empty());
1891    }
1892
1893    #[test]
1894    fn test_single_table_unused_alias_detected() {
1895        let issues = check_sql("SELECT * FROM users u");
1896        assert_eq!(issues.len(), 1);
1897        assert!(issues[0].message.contains("u"));
1898    }
1899
1900    #[test]
1901    fn test_single_table_alias_used_ok() {
1902        let issues = check_sql("SELECT u.id FROM users u");
1903        assert!(issues.is_empty());
1904    }
1905
1906    // --- Edge cases adopted from sqlfluff aliasing rules ---
1907
1908    #[test]
1909    fn test_alias_used_in_where() {
1910        let issues = check_sql(
1911            "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.active = true",
1912        );
1913        assert!(issues.is_empty());
1914    }
1915
1916    #[test]
1917    fn test_alias_used_in_group_by() {
1918        let issues = check_sql(
1919            "SELECT u.name, COUNT(*) FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.name",
1920        );
1921        assert!(issues.is_empty());
1922    }
1923
1924    #[test]
1925    fn test_alias_used_in_having() {
1926        let issues = check_sql(
1927            "SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id \
1928             GROUP BY u.name HAVING COUNT(o.id) > 5",
1929        );
1930        assert!(issues.is_empty());
1931    }
1932
1933    #[test]
1934    fn test_alias_used_in_qualified_wildcard() {
1935        // u is used via u.*, o is used in JOIN ON condition
1936        let issues = check_sql("SELECT u.* FROM users u JOIN orders o ON u.id = o.user_id");
1937        assert!(issues.is_empty());
1938    }
1939
1940    #[test]
1941    fn test_alias_unused_despite_qualified_wildcard() {
1942        // u is used via u.*, but o is never referenced (join uses full table name)
1943        let issues = check_sql("SELECT u.* FROM users u JOIN orders o ON u.id = orders.user_id");
1944        assert_eq!(issues.len(), 1);
1945        assert!(issues[0].message.contains("o"));
1946    }
1947
1948    #[test]
1949    fn test_partial_alias_usage() {
1950        // Only one of two aliases is used
1951        let issues = check_sql("SELECT u.name FROM users u JOIN orders o ON u.id = orders.user_id");
1952        assert_eq!(issues.len(), 1);
1953        assert!(issues[0].message.contains("o"));
1954    }
1955
1956    #[test]
1957    fn test_three_tables_one_unused() {
1958        let issues = check_sql(
1959            "SELECT a.name, b.total \
1960             FROM users a \
1961             JOIN orders b ON a.id = b.user_id \
1962             JOIN products c ON b.product_id = products.id",
1963        );
1964        assert_eq!(issues.len(), 1);
1965        assert!(issues[0].message.contains("c"));
1966    }
1967
1968    #[test]
1969    fn test_no_aliases_ok() {
1970        let issues =
1971            check_sql("SELECT users.name FROM users JOIN orders ON users.id = orders.user_id");
1972        assert!(issues.is_empty());
1973    }
1974
1975    #[test]
1976    fn test_self_join_with_aliases() {
1977        let issues =
1978            check_sql("SELECT a.name, b.name FROM users a JOIN users b ON a.manager_id = b.id");
1979        assert!(issues.is_empty());
1980    }
1981
1982    #[test]
1983    fn test_alias_in_case_expression() {
1984        let issues = check_sql(
1985            "SELECT CASE WHEN u.active THEN 'yes' ELSE 'no' END \
1986             FROM users u JOIN orders o ON u.id = o.user_id",
1987        );
1988        // u is used in CASE, o is used in JOIN ON
1989        assert!(issues.is_empty());
1990    }
1991
1992    #[test]
1993    fn test_alias_used_in_order_by() {
1994        let issues = check_sql(
1995            "SELECT u.name \
1996             FROM users u \
1997             JOIN orders o ON users.id = orders.user_id \
1998             ORDER BY o.created_at",
1999        );
2000        assert!(issues.is_empty());
2001    }
2002
2003    #[test]
2004    fn test_left_join_alias_used_in_on_clause() {
2005        let issues = check_sql("SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id");
2006        assert!(issues.is_empty());
2007    }
2008
2009    #[test]
2010    fn test_alias_used_only_in_correlated_exists_subquery() {
2011        let issues = check_sql(
2012            "SELECT 1 \
2013             FROM users u \
2014             JOIN orders o ON 1 = 1 \
2015             WHERE EXISTS (SELECT 1 WHERE u.id = o.user_id)",
2016        );
2017        assert!(issues.is_empty());
2018    }
2019
2020    #[test]
2021    fn test_alias_used_in_qualify_clause() {
2022        let issues = check_sql(
2023            "SELECT u.id \
2024             FROM users u \
2025             JOIN orders o ON users.id = orders.user_id \
2026             QUALIFY ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.user_id) = 1",
2027        );
2028        assert!(issues.is_empty());
2029    }
2030
2031    #[test]
2032    fn test_alias_used_in_named_window_clause() {
2033        let issues = check_sql(
2034            "SELECT SUM(u.id) OVER w \
2035             FROM users u \
2036             JOIN orders o ON users.id = orders.user_id \
2037             WINDOW w AS (PARTITION BY o.user_id)",
2038        );
2039        assert!(issues.is_empty());
2040    }
2041
2042    #[test]
2043    fn test_unused_derived_alias_detected() {
2044        let issues = check_sql(
2045            "SELECT u.id \
2046             FROM users u \
2047             JOIN (SELECT id FROM orders) o2 ON u.id = u.id",
2048        );
2049        assert!(issues.is_empty());
2050    }
2051
2052    #[test]
2053    fn test_lateral_alias_is_ignored() {
2054        let issues = check_sql("SELECT u.id FROM users u JOIN LATERAL (SELECT 1) lx ON TRUE");
2055        assert!(issues.is_empty());
2056    }
2057
2058    #[test]
2059    fn test_alias_used_only_in_lateral_subquery_relation() {
2060        let issues = check_sql(
2061            "SELECT 1 \
2062             FROM users u \
2063             JOIN LATERAL (SELECT u.id) lx ON TRUE",
2064        );
2065        assert!(issues.is_empty());
2066    }
2067
2068    #[test]
2069    fn test_alias_used_only_in_unnest_join_relation() {
2070        let issues = check_sql(
2071            "SELECT 1 \
2072             FROM users u \
2073             LEFT JOIN UNNEST(u.tags) tag ON TRUE",
2074        );
2075        assert!(issues.is_empty());
2076    }
2077
2078    #[test]
2079    fn alias_case_check_case_sensitive_flags_case_mismatch() {
2080        let config = LintConfig {
2081            enabled: true,
2082            disabled_rules: vec![],
2083            rule_configs: std::collections::BTreeMap::from([(
2084                "aliasing.unused".to_string(),
2085                serde_json::json!({"alias_case_check": "case_sensitive"}),
2086            )]),
2087        };
2088        let rule = UnusedTableAlias::from_config(&config);
2089        let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id";
2090        let stmts = parse_sql(sql).expect("parse");
2091        let issues = rule.check(
2092            &stmts[0],
2093            &LintContext {
2094                sql,
2095                statement_range: 0..sql.len(),
2096                statement_index: 0,
2097            },
2098        );
2099        assert_eq!(issues.len(), 1);
2100        assert!(issues[0].message.contains("Zoo"));
2101    }
2102
2103    #[test]
2104    fn alias_case_check_case_insensitive_allows_case_mismatch() {
2105        let config = LintConfig {
2106            enabled: true,
2107            disabled_rules: vec![],
2108            rule_configs: std::collections::BTreeMap::from([(
2109                "LINT_AL_005".to_string(),
2110                serde_json::json!({"alias_case_check": "case_insensitive"}),
2111            )]),
2112        };
2113        let rule = UnusedTableAlias::from_config(&config);
2114        let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id";
2115        let stmts = parse_sql(sql).expect("parse");
2116        let issues = rule.check(
2117            &stmts[0],
2118            &LintContext {
2119                sql,
2120                statement_range: 0..sql.len(),
2121                statement_index: 0,
2122            },
2123        );
2124        assert!(issues.is_empty());
2125    }
2126
2127    #[test]
2128    fn alias_case_check_quoted_cs_naked_upper_allows_unquoted_upper_fold_for_quoted_alias() {
2129        let config = LintConfig {
2130            enabled: true,
2131            disabled_rules: vec![],
2132            rule_configs: std::collections::BTreeMap::from([(
2133                "aliasing.unused".to_string(),
2134                serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
2135            )]),
2136        };
2137        let rule = UnusedTableAlias::from_config(&config);
2138        let sql = "SELECT foo.id, b.id FROM users AS \"FOO\" JOIN books b ON foo.id = b.user_id";
2139        let stmts = parse_sql(sql).expect("parse");
2140        let issues = rule.check(
2141            &stmts[0],
2142            &LintContext {
2143                sql,
2144                statement_range: 0..sql.len(),
2145                statement_index: 0,
2146            },
2147        );
2148        assert!(issues.is_empty());
2149    }
2150
2151    #[test]
2152    fn alias_case_check_quoted_cs_naked_lower_allows_unquoted_lower_fold_for_quoted_alias() {
2153        let config = LintConfig {
2154            enabled: true,
2155            disabled_rules: vec![],
2156            rule_configs: std::collections::BTreeMap::from([(
2157                "aliasing.unused".to_string(),
2158                serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
2159            )]),
2160        };
2161        let rule = UnusedTableAlias::from_config(&config);
2162        let sql = "SELECT FOO.id, b.id FROM users AS \"foo\" JOIN books b ON FOO.id = b.user_id";
2163        let stmts = parse_sql(sql).expect("parse");
2164        let issues = rule.check(
2165            &stmts[0],
2166            &LintContext {
2167                sql,
2168                statement_range: 0..sql.len(),
2169                statement_index: 0,
2170            },
2171        );
2172        assert!(issues.is_empty());
2173    }
2174
2175    #[test]
2176    fn dialect_mode_generic_allows_quoted_unquoted_fold_match() {
2177        let issues = check_sql("SELECT a.col1 FROM tab1 AS \"A\"");
2178        assert!(issues.is_empty());
2179    }
2180
2181    #[test]
2182    fn dialect_mode_generic_allows_quoted_prefix_against_unquoted_alias() {
2183        let issues = check_sql("SELECT \"A\".col1 FROM tab1 AS a");
2184        assert!(issues.is_empty());
2185    }
2186
2187    #[test]
2188    fn dialect_mode_generic_flags_single_quoted_alias_case_mismatch() {
2189        let issues = check_sql("SELECT a.col1 FROM tab1 AS 'a'");
2190        assert_eq!(issues.len(), 1);
2191        assert!(issues[0].message.contains("a"));
2192    }
2193
2194    #[test]
2195    fn dialect_mode_postgres_allows_lower_fold_for_quoted_alias() {
2196        let issues =
2197            check_sql_in_dialect("SELECT A.col_1 FROM table_a AS \"a\"", Dialect::Postgres);
2198        assert!(issues.is_empty());
2199    }
2200
2201    #[test]
2202    fn dialect_mode_snowflake_flags_mixed_quoted_case_mismatch() {
2203        let issues =
2204            check_sql_in_dialect("SELECT a.col_1 FROM table_a AS \"a\"", Dialect::Snowflake);
2205        assert_eq!(issues.len(), 1);
2206        assert!(issues[0].message.contains("a"));
2207    }
2208
2209    #[test]
2210    fn dialect_mode_bigquery_allows_backtick_quoted_alias_fold_match() {
2211        let issues = check_sql_in_dialect("SELECT a.col1 FROM tab1 AS `A`", Dialect::Bigquery);
2212        assert!(issues.is_empty());
2213    }
2214
2215    #[test]
2216    fn dialect_mode_redshift_allows_lower_fold_for_quoted_alias() {
2217        let issues =
2218            check_sql_in_dialect("SELECT A.col_1 FROM table_a AS \"a\"", Dialect::Redshift);
2219        assert!(issues.is_empty());
2220    }
2221
2222    #[test]
2223    fn dialect_mode_redshift_flags_mixed_quoted_case_mismatch() {
2224        let issues =
2225            check_sql_in_dialect("SELECT a.col_1 FROM table_a AS \"A\"", Dialect::Redshift);
2226        assert_eq!(issues.len(), 1);
2227        assert!(issues[0].message.contains("A"));
2228    }
2229
2230    #[test]
2231    fn dialect_mode_mysql_allows_backtick_qualified_reference_against_unquoted_alias() {
2232        let issues = check_sql_in_dialect(
2233            "SELECT `nih`.`userID` FROM `flight_notification_item_history` AS nih",
2234            Dialect::Mysql,
2235        );
2236        assert!(issues.is_empty());
2237    }
2238
2239    #[test]
2240    fn dialect_mode_duckdb_allows_case_insensitive_quoted_reference() {
2241        let issues = check_sql_in_dialect("SELECT \"a\".col_1 FROM table_a AS A", Dialect::Duckdb);
2242        assert!(issues.is_empty());
2243    }
2244
2245    #[test]
2246    fn dialect_mode_hive_allows_case_insensitive_quoted_reference() {
2247        let issues = check_sql_in_dialect("SELECT `a`.col1 FROM tab1 AS A", Dialect::Hive);
2248        assert!(issues.is_empty());
2249    }
2250
2251    #[test]
2252    fn flags_inner_subquery_unused_alias() {
2253        let issues = check_sql("SELECT * FROM (SELECT * FROM my_tbl AS foo)");
2254        assert_eq!(issues.len(), 1);
2255        assert!(issues[0].message.contains("foo"));
2256    }
2257
2258    #[test]
2259    fn allows_unreferenced_subquery_alias() {
2260        let issues = check_sql("SELECT * FROM (SELECT 1 AS a) subquery");
2261        assert!(issues.is_empty());
2262    }
2263
2264    #[test]
2265    fn allows_postgres_generate_series_alias() {
2266        let issues = check_sql_in_dialect(
2267            "SELECT date_trunc('day', dd)::timestamp FROM generate_series('2022-02-01'::timestamp, NOW()::timestamp, '1 day'::interval) dd",
2268            Dialect::Postgres,
2269        );
2270        assert!(issues.is_empty());
2271    }
2272
2273    #[test]
2274    fn flags_unused_snowflake_lateral_flatten_alias() {
2275        let issues = check_sql_in_dialect(
2276            "SELECT a.test1, a.test2, b.test3 \
2277             FROM table1 AS a, \
2278             LATERAL flatten(input => some_field) AS b, \
2279             LATERAL flatten(input => b.value) AS c, \
2280             LATERAL flatten(input => c.value) AS d, \
2281             LATERAL flatten(input => d.value) AS e, \
2282             LATERAL flatten(input => e.value) AS f",
2283            Dialect::Snowflake,
2284        );
2285        assert_eq!(issues.len(), 1);
2286        assert!(issues[0].message.contains("f"));
2287    }
2288
2289    #[test]
2290    fn flags_unused_alias_inside_snowflake_delete_using_cte() {
2291        let issues = check_sql_in_dialect(
2292            "DELETE FROM MYTABLE1 \
2293             USING ( \
2294                 WITH MYCTE AS (SELECT COLUMN2 FROM MYTABLE3 AS MT3) \
2295                 SELECT COLUMN3 FROM MYTABLE3 \
2296             ) X \
2297             WHERE COLUMN1 = X.COLUMN3",
2298            Dialect::Snowflake,
2299        );
2300        assert_eq!(issues.len(), 1);
2301        assert!(issues[0].message.contains("MT3"));
2302    }
2303
2304    #[test]
2305    fn allows_bigquery_to_json_string_table_alias_argument() {
2306        let issues = check_sql_in_dialect(
2307            "SELECT TO_JSON_STRING(t) FROM my_table AS t",
2308            Dialect::Bigquery,
2309        );
2310        assert!(issues.is_empty());
2311    }
2312
2313    #[test]
2314    fn flags_ansi_to_json_string_table_alias_argument() {
2315        let issues =
2316            check_sql_in_dialect("SELECT TO_JSON_STRING(t) FROM my_table AS t", Dialect::Ansi);
2317        assert_eq!(issues.len(), 1);
2318        assert!(issues[0].message.contains("t"));
2319    }
2320
2321    #[test]
2322    fn redshift_qualify_after_from_counts_alias_usage() {
2323        let issues = check_sql_in_dialect(
2324            "SELECT * \
2325             FROM store AS s \
2326             INNER JOIN store_sales AS ss \
2327             QUALIFY ROW_NUMBER() OVER (PARTITION BY ss.sold_date ORDER BY ss.sales_price DESC) <= 2",
2328            Dialect::Redshift,
2329        );
2330        assert_eq!(issues.len(), 1);
2331        assert!(issues[0].message.contains("s"));
2332    }
2333
2334    #[test]
2335    fn redshift_qualify_after_where_does_not_count_alias_usage() {
2336        let issues = check_sql_in_dialect(
2337            "SELECT * \
2338             FROM store AS s \
2339             INNER JOIN store_sales AS ss \
2340             WHERE col = 1 \
2341             QUALIFY ROW_NUMBER() OVER (PARTITION BY ss.sold_date ORDER BY ss.sales_price DESC) <= 2",
2342            Dialect::Redshift,
2343        );
2344        assert_eq!(issues.len(), 2);
2345        assert!(issues.iter().any(|issue| issue.message.contains("s")));
2346        assert!(issues.iter().any(|issue| issue.message.contains("ss")));
2347    }
2348
2349    #[test]
2350    fn redshift_qualify_unqualified_alias_prefixed_identifier_counts_alias_usage() {
2351        let issues = check_sql_in_dialect(
2352            "SELECT * \
2353             FROM #store_sales AS ss \
2354             QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2",
2355            Dialect::Redshift,
2356        );
2357        assert!(issues.is_empty());
2358    }
2359
2360    #[test]
2361    fn redshift_qualify_after_from_autofix_keeps_used_join_alias() {
2362        let sql = "SELECT *\n\
2363FROM #store as s\n\
2364INNER JOIN #store_sales AS ss\n\
2365QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2";
2366        let issues = check_sql_in_dialect(sql, Dialect::Redshift);
2367        assert_eq!(issues.len(), 1);
2368        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2369        assert_eq!(
2370            fixed,
2371            "SELECT *\n\
2372FROM #store\n\
2373INNER JOIN #store_sales AS ss\n\
2374QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2"
2375        );
2376    }
2377
2378    #[test]
2379    fn redshift_qualify_after_where_autofix_removes_both_unused_aliases() {
2380        let sql = "SELECT *\n\
2381FROM #store as s\n\
2382INNER JOIN #store_sales AS ss\n\
2383WHERE col = 1\n\
2384QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2";
2385        let issues = check_sql_in_dialect(sql, Dialect::Redshift);
2386        assert_eq!(issues.len(), 2);
2387        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2388        assert_eq!(
2389            fixed,
2390            "SELECT *\n\
2391FROM #store\n\
2392INNER JOIN #store_sales\n\
2393WHERE col = 1\n\
2394QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2"
2395        );
2396    }
2397
2398    #[test]
2399    fn sparksql_values_derived_alias_is_detected_and_autofixed() {
2400        let sql = "SELECT *\n\
2401FROM (\n\
2402    VALUES (1, 2), (3, 4)\n\
2403) AS t(c1, c2)";
2404        let issues = check_sql_in_dialect(sql, Dialect::Databricks);
2405        assert_eq!(issues.len(), 1);
2406        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2407        assert_eq!(
2408            fixed,
2409            "SELECT *\n\
2410FROM (\n\
2411    VALUES (1, 2), (3, 4)\n\
2412)"
2413        );
2414    }
2415
2416    #[test]
2417    fn allows_bigquery_implicit_array_table_reference() {
2418        let issues = check_sql_in_dialect(
2419            "WITH table_arr AS (SELECT [1,2,4,2] AS arr) \
2420             SELECT arr \
2421             FROM table_arr AS t, t.arr",
2422            Dialect::Bigquery,
2423        );
2424        assert!(issues.is_empty());
2425    }
2426
2427    #[test]
2428    fn allows_redshift_super_array_relation_reference() {
2429        let issues = check_sql_in_dialect(
2430            "SELECT my_column, my_array_value \
2431             FROM my_schema.my_table AS t, t.super_array AS my_array_value",
2432            Dialect::Redshift,
2433        );
2434        assert!(issues.is_empty());
2435    }
2436
2437    #[test]
2438    fn allows_repeat_referenced_table_aliases() {
2439        let issues = check_sql(
2440            "SELECT ROW_NUMBER() OVER(PARTITION BY a.object_id ORDER BY a.object_id) \
2441             FROM sys.objects a \
2442             CROSS JOIN sys.objects b \
2443             CROSS JOIN sys.objects c",
2444        );
2445        assert!(issues.is_empty());
2446    }
2447
2448    #[test]
2449    fn dialect_mode_databricks_allows_backtick_case_insensitive_reference() {
2450        let issues =
2451            check_sql_in_dialect("SELECT `a`.col_1 FROM table_a AS A", Dialect::Databricks);
2452        assert!(issues.is_empty());
2453    }
2454
2455    #[test]
2456    fn snowflake_json_access_counts_as_alias_usage() {
2457        let issues = check_sql_in_dialect(
2458            "SELECT r.rec:foo::string FROM foo.bar AS r",
2459            Dialect::Snowflake,
2460        );
2461        assert!(issues.is_empty());
2462    }
2463
2464    #[test]
2465    fn snowflake_lateral_flatten_unused_alias_detected_and_fixable() {
2466        let sql = "SELECT r.rec:foo::string, value:bar::string \
2467                   FROM foo.bar AS r, LATERAL FLATTEN(input => rec:result) AS x";
2468        let issues = check_sql_in_dialect(sql, Dialect::Snowflake);
2469        assert_eq!(issues.len(), 1);
2470        assert!(issues[0].message.contains("x"));
2471        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
2472        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
2473        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2474        assert!(
2475            !fixed.contains("AS x"),
2476            "autofix should remove the unused LATERAL alias"
2477        );
2478    }
2479
2480    #[test]
2481    fn autofix_removes_double_quoted_alias_in_dialect_mode() {
2482        let sql = "SELECT a.col_1\nFROM table_a AS \"A\"\n";
2483        let issues = check_sql_in_dialect(sql, Dialect::Postgres);
2484        assert_eq!(issues.len(), 1);
2485        assert!(issues[0].message.contains("A"));
2486        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
2487        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2488        assert_eq!(fixed, "SELECT a.col_1\nFROM table_a\n");
2489        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
2490    }
2491
2492    #[test]
2493    fn autofix_removes_single_quoted_alias() {
2494        let sql = "SELECT a.col1\nFROM tab1 as 'a'\n";
2495        let issues = check_sql(sql);
2496        assert_eq!(issues.len(), 1);
2497        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
2498        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
2499        assert_eq!(fixed, "SELECT a.col1\nFROM tab1\n");
2500        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
2501    }
2502}