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