Skip to main content

flowscope_core/linter/rules/
al_007.rs

1//! LINT_AL_007: Forbid unnecessary alias.
2//!
3//! SQLFluff AL07 parity: base-table aliases are unnecessary unless they are
4//! needed to disambiguate repeated references to the same table (self-joins).
5
6use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
9use sqlparser::ast::{Ident, Select, Statement, TableFactor, TableWithJoins};
10use sqlparser::keywords::Keyword;
11use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
12use std::collections::HashMap;
13
14use super::semantic_helpers::visit_selects_in_statement;
15
16#[derive(Default)]
17pub struct AliasingForbidSingleTable {
18    force_enable: bool,
19}
20
21impl AliasingForbidSingleTable {
22    pub fn from_config(config: &LintConfig) -> Self {
23        Self {
24            force_enable: config
25                .rule_option_bool(issue_codes::LINT_AL_007, "force_enable")
26                .unwrap_or(false),
27        }
28    }
29}
30
31impl LintRule for AliasingForbidSingleTable {
32    fn code(&self) -> &'static str {
33        issue_codes::LINT_AL_007
34    }
35
36    fn name(&self) -> &'static str {
37        "Forbid unnecessary alias"
38    }
39
40    fn description(&self) -> &'static str {
41        "Avoid table aliases in from clauses and join conditions."
42    }
43
44    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
45        if !self.force_enable {
46            return Vec::new();
47        }
48
49        let tokens =
50            tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
51
52        let mut issues = Vec::new();
53
54        visit_selects_in_statement(statement, &mut |select| {
55            let aliases = collect_unnecessary_aliases(select);
56            for alias_info in &aliases {
57                let edits = build_autofix_edits(alias_info, &aliases, ctx, tokens.as_deref());
58                let mut issue =
59                    Issue::info(issue_codes::LINT_AL_007, "Avoid unnecessary table aliases.")
60                        .with_statement(ctx.statement_index);
61                if !edits.is_empty() {
62                    issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
63                }
64                issues.push(issue);
65            }
66        });
67
68        if issues.is_empty() {
69            if let Some(issue) = fallback_single_from_alias_issue(ctx, tokens.as_deref()) {
70                issues.push(issue);
71            }
72        }
73
74        issues
75    }
76}
77
78#[derive(Clone)]
79struct FallbackAliasCandidate {
80    table_name: String,
81    alias_name: String,
82    alias_start: usize,
83    alias_end: usize,
84}
85
86fn fallback_single_from_alias_issue(
87    ctx: &LintContext,
88    tokens: Option<&[LocatedToken]>,
89) -> Option<Issue> {
90    if ctx.dialect() != Dialect::Mssql {
91        return None;
92    }
93    let tokens = tokens?;
94    if tokens.is_empty() || contains_join_keyword(tokens) {
95        return None;
96    }
97
98    let candidate = fallback_single_from_alias_candidate(tokens, ctx.statement_sql())?;
99    let mut edits = Vec::new();
100
101    if let Some(delete_span) =
102        alias_declaration_delete_span(tokens, candidate.alias_start, candidate.alias_end)
103    {
104        edits.push(IssuePatchEdit::new(
105            ctx.span_from_statement_offset(delete_span.start, delete_span.end),
106            "",
107        ));
108    }
109
110    for (ref_start, ref_end) in find_qualified_alias_references(tokens, &candidate.alias_name, &[])
111    {
112        edits.push(IssuePatchEdit::new(
113            ctx.span_from_statement_offset(ref_start, ref_end),
114            candidate.table_name.clone(),
115        ));
116    }
117
118    let mut issue = Issue::info(issue_codes::LINT_AL_007, "Avoid unnecessary table aliases.")
119        .with_statement(ctx.statement_index)
120        .with_span(ctx.span_from_statement_offset(candidate.alias_start, candidate.alias_end));
121
122    if !edits.is_empty() {
123        issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
124    }
125
126    Some(issue)
127}
128
129fn fallback_single_from_alias_candidate(
130    tokens: &[LocatedToken],
131    sql: &str,
132) -> Option<FallbackAliasCandidate> {
133    for (index, token) in tokens.iter().enumerate() {
134        if !token_is_keyword(&token.token, "FROM") {
135            continue;
136        }
137
138        let table_start_idx = next_non_trivia_index(tokens, index + 1)?;
139        if !is_identifier_token(&tokens[table_start_idx].token) {
140            continue;
141        }
142
143        let mut table_end_idx = table_start_idx;
144        loop {
145            let Some(dot_idx) = next_non_trivia_index(tokens, table_end_idx + 1) else {
146                break;
147            };
148            if !matches!(tokens[dot_idx].token, Token::Period) {
149                break;
150            }
151            let Some(next_part_idx) = next_non_trivia_index(tokens, dot_idx + 1) else {
152                break;
153            };
154            if !is_identifier_token(&tokens[next_part_idx].token) {
155                break;
156            }
157            table_end_idx = next_part_idx;
158        }
159
160        let alias_idx = next_non_trivia_index(tokens, table_end_idx + 1)?;
161        let Token::Word(alias_word) = &tokens[alias_idx].token else {
162            continue;
163        };
164        if alias_word.keyword != Keyword::NoKeyword {
165            continue;
166        }
167
168        let table_start = tokens[table_start_idx].start;
169        let table_end = tokens[table_end_idx].end;
170        if table_start >= table_end || table_end > sql.len() {
171            continue;
172        }
173
174        return Some(FallbackAliasCandidate {
175            table_name: sql[table_start..table_end].to_string(),
176            alias_name: alias_word.value.clone(),
177            alias_start: tokens[alias_idx].start,
178            alias_end: tokens[alias_idx].end,
179        });
180    }
181
182    None
183}
184
185fn token_is_keyword(token: &Token, keyword: &str) -> bool {
186    matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(keyword))
187}
188
189fn is_identifier_token(token: &Token) -> bool {
190    matches!(token, Token::Word(_) | Token::Placeholder(_))
191}
192
193fn next_non_trivia_index(tokens: &[LocatedToken], mut index: usize) -> Option<usize> {
194    while index < tokens.len() {
195        if !is_trivia_token(&tokens[index].token) {
196            return Some(index);
197        }
198        index += 1;
199    }
200    None
201}
202
203fn contains_join_keyword(tokens: &[LocatedToken]) -> bool {
204    tokens.iter().any(|token| {
205        matches!(
206            &token.token,
207            Token::Word(word)
208                if word.value.eq_ignore_ascii_case("JOIN")
209                    || word.value.eq_ignore_ascii_case("LEFT")
210                    || word.value.eq_ignore_ascii_case("RIGHT")
211                    || word.value.eq_ignore_ascii_case("FULL")
212                    || word.value.eq_ignore_ascii_case("INNER")
213                    || word.value.eq_ignore_ascii_case("CROSS")
214        )
215    })
216}
217
218/// Information about a single unnecessary alias.
219#[derive(Clone)]
220struct UnnecessaryAlias {
221    /// The original table name as written (e.g. "users", "SchemaA.TableB").
222    table_name: String,
223    /// The alias identifier from the AST (carries span information).
224    alias_ident: Ident,
225}
226
227/// Collects all unnecessary aliases from a SELECT clause.
228fn collect_unnecessary_aliases(select: &Select) -> Vec<UnnecessaryAlias> {
229    let mut candidates = Vec::new();
230    for table in &select.from {
231        collect_alias_candidates_from_table_with_joins(table, &mut candidates);
232    }
233
234    if candidates.is_empty() {
235        return Vec::new();
236    }
237
238    // Count how many times each table appears to detect self-joins.
239    let mut table_occurrence_counts: HashMap<String, usize> = HashMap::new();
240    for (canonical, _has_alias, _table_name, _alias) in &candidates {
241        *table_occurrence_counts
242            .entry(canonical.clone())
243            .or_insert(0) += 1;
244    }
245
246    let is_multi_source = candidates.len() > 1;
247
248    candidates
249        .into_iter()
250        .filter_map(|(canonical, has_alias, table_name, alias_ident)| {
251            if !has_alias {
252                return None;
253            }
254            if is_multi_source
255                && table_occurrence_counts
256                    .get(&canonical)
257                    .copied()
258                    .unwrap_or(0)
259                    > 1
260            {
261                return None;
262            }
263            Some(UnnecessaryAlias {
264                table_name,
265                alias_ident: alias_ident?,
266            })
267        })
268        .collect()
269}
270
271type AliasCandidate = (
272    String,        // canonical name (uppercase)
273    bool,          // has_alias
274    String,        // original table name as written
275    Option<Ident>, // alias ident
276);
277
278fn collect_alias_candidates_from_table_with_joins(
279    table: &TableWithJoins,
280    candidates: &mut Vec<AliasCandidate>,
281) {
282    collect_alias_candidates_from_table_factor(&table.relation, candidates);
283    for join in &table.joins {
284        collect_alias_candidates_from_table_factor(&join.relation, candidates);
285    }
286}
287
288fn collect_alias_candidates_from_table_factor(
289    table_factor: &TableFactor,
290    candidates: &mut Vec<AliasCandidate>,
291) {
292    match table_factor {
293        TableFactor::Table { name, alias, .. } => {
294            let canonical = name.to_string().to_ascii_uppercase();
295            let table_name = name.to_string();
296            let alias_ident = alias.as_ref().map(|a| a.name.clone());
297            candidates.push((canonical, alias.is_some(), table_name, alias_ident));
298        }
299        TableFactor::NestedJoin {
300            table_with_joins, ..
301        } => {
302            collect_alias_candidates_from_table_with_joins(table_with_joins, candidates);
303        }
304        TableFactor::Pivot { table, .. }
305        | TableFactor::Unpivot { table, .. }
306        | TableFactor::MatchRecognize { table, .. } => {
307            collect_alias_candidates_from_table_factor(table, candidates);
308        }
309        _ => {}
310    }
311}
312
313/// Builds autofix edits for a single unnecessary alias violation.
314///
315/// Generates two types of edits:
316/// 1. Delete the alias declaration (e.g., ` AS u` or ` u`)
317/// 2. Replace qualified alias references (e.g., `u.id` → `users.id`)
318fn build_autofix_edits(
319    alias_info: &UnnecessaryAlias,
320    all_aliases: &[UnnecessaryAlias],
321    ctx: &LintContext,
322    tokens: Option<&[LocatedToken]>,
323) -> Vec<IssuePatchEdit> {
324    let Some(tokens) = tokens else {
325        return Vec::new();
326    };
327
328    let alias_name = &alias_info.alias_ident.value;
329    let table_name = &alias_info.table_name;
330
331    // Find the alias declaration span and delete it.
332    let alias_abs_start = line_col_to_offset(
333        ctx.sql,
334        alias_info.alias_ident.span.start.line as usize,
335        alias_info.alias_ident.span.start.column as usize,
336    );
337    let alias_abs_end = line_col_to_offset(
338        ctx.sql,
339        alias_info.alias_ident.span.end.line as usize,
340        alias_info.alias_ident.span.end.column as usize,
341    );
342    let (Some(alias_abs_start), Some(alias_abs_end)) = (alias_abs_start, alias_abs_end) else {
343        return Vec::new();
344    };
345
346    if alias_abs_start < ctx.statement_range.start || alias_abs_end > ctx.statement_range.end {
347        return Vec::new();
348    }
349
350    let rel_alias_start = alias_abs_start - ctx.statement_range.start;
351    let rel_alias_end = alias_abs_end - ctx.statement_range.start;
352
353    let mut edits = Vec::new();
354
355    // Find the extent to delete: look back from alias for AS keyword + whitespace.
356    if let Some(delete_span) = alias_declaration_delete_span(tokens, rel_alias_start, rel_alias_end)
357    {
358        edits.push(IssuePatchEdit::new(
359            ctx.span_from_statement_offset(delete_span.start, delete_span.end),
360            "",
361        ));
362    }
363
364    // Find all qualified references to this alias (e.g., `u.id`) and replace with table name.
365    let alias_refs = find_qualified_alias_references(tokens, alias_name, all_aliases);
366    for (ref_start, ref_end) in alias_refs {
367        edits.push(IssuePatchEdit::new(
368            ctx.span_from_statement_offset(ref_start, ref_end),
369            table_name.clone(),
370        ));
371    }
372
373    edits
374}
375
376/// Determines the span to delete for an alias declaration.
377///
378/// Given the alias identifier position, looks backwards for an optional `AS` keyword
379/// and the whitespace between the table name and the alias.
380/// Returns the span `[whitespace_start..alias_end]` to delete.
381fn alias_declaration_delete_span(
382    tokens: &[LocatedToken],
383    alias_start: usize,
384    alias_end: usize,
385) -> Option<Span> {
386    // Walk backwards from alias_start to find:
387    // 1. Optional whitespace before alias
388    // 2. Optional AS keyword before that whitespace
389    // 3. Whitespace before AS keyword (if AS was found)
390
391    let mut delete_start = alias_start;
392
393    // Find the token just before alias_start (skip trivia to find AS or table name).
394    let mut found_as = false;
395    for token in tokens.iter().rev() {
396        if token.end > alias_start {
397            continue;
398        }
399        if is_trivia_token(&token.token) {
400            // Include whitespace in the deletion.
401            if token.start < delete_start {
402                delete_start = token.start;
403            }
404            continue;
405        }
406        if is_as_token(&token.token) {
407            // Include AS keyword in the deletion.
408            found_as = true;
409            delete_start = token.start;
410        }
411        break;
412    }
413
414    // If we found AS, also include whitespace before it.
415    if found_as {
416        for token in tokens.iter().rev() {
417            if token.end > delete_start {
418                continue;
419            }
420            if is_trivia_token(&token.token) {
421                if token.start < delete_start {
422                    delete_start = token.start;
423                }
424                continue;
425            }
426            break;
427        }
428    }
429
430    if delete_start < alias_end {
431        Some(Span::new(delete_start, alias_end))
432    } else {
433        None
434    }
435}
436
437/// Finds all qualified alias references in the token stream.
438///
439/// Looks for patterns like `alias.column` where `alias` matches the given name.
440/// Only matches when followed by a `.` (dot) to avoid replacing bare identifiers
441/// that happen to match the alias name (e.g., `ORDER BY o DESC` where `o` is a column).
442fn find_qualified_alias_references(
443    tokens: &[LocatedToken],
444    alias_name: &str,
445    all_aliases: &[UnnecessaryAlias],
446) -> Vec<(usize, usize)> {
447    let mut refs = Vec::new();
448
449    for (i, token) in tokens.iter().enumerate() {
450        // Look for Word tokens that match the alias name.
451        let Token::Word(word) = &token.token else {
452            continue;
453        };
454        if !word.value.eq_ignore_ascii_case(alias_name) {
455            continue;
456        }
457        // Must be followed by a dot to be a qualified reference.
458        let next_non_trivia = tokens[i + 1..].iter().find(|t| !is_trivia_token(&t.token));
459        if !next_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
460            continue;
461        }
462        // Must NOT be preceded by a dot (would be a schema-qualified name, not an alias ref).
463        let prev_non_trivia = tokens[..i]
464            .iter()
465            .rev()
466            .find(|t| !is_trivia_token(&t.token));
467        if prev_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
468            continue;
469        }
470        // Skip if this token's position is at an alias declaration site.
471        if all_aliases.iter().any(|a| {
472            let a_start = line_col_to_absolute_offset(
473                a.alias_ident.span.start.line as usize,
474                a.alias_ident.span.start.column as usize,
475            );
476            a_start.is_some_and(|s| s == token.start)
477        }) {
478            continue;
479        }
480        refs.push((token.start, token.end));
481    }
482
483    refs
484}
485
486// This is a simplified version that doesn't require the full SQL text.
487// It works because token offsets are already relative to the statement.
488fn line_col_to_absolute_offset(_line: usize, _column: usize) -> Option<usize> {
489    // We can't compute this without the SQL text. This function is a placeholder;
490    // the actual skip logic uses direct offset comparison.
491    None
492}
493
494#[derive(Clone)]
495struct LocatedToken {
496    token: Token,
497    start: usize,
498    end: usize,
499}
500
501fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
502    let dialect = dialect.to_sqlparser_dialect();
503    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
504    let tokens = tokenizer.tokenize_with_location().ok()?;
505
506    let mut out = Vec::with_capacity(tokens.len());
507    for token in tokens {
508        let (start, end) = token_with_span_offsets(sql, &token)?;
509        out.push(LocatedToken {
510            token: token.token,
511            start,
512            end,
513        });
514    }
515    Some(out)
516}
517
518fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
519    let statement_start = ctx.statement_range.start;
520    ctx.with_document_tokens(|tokens| {
521        if tokens.is_empty() {
522            return None;
523        }
524
525        Some(
526            tokens
527                .iter()
528                .filter_map(|token| {
529                    let (start, end) = token_with_span_offsets(ctx.sql, token)?;
530                    if start < ctx.statement_range.start || end > ctx.statement_range.end {
531                        return None;
532                    }
533                    Some(LocatedToken {
534                        token: token.token.clone(),
535                        start: start - statement_start,
536                        end: end - statement_start,
537                    })
538                })
539                .collect::<Vec<_>>(),
540        )
541    })
542}
543
544fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
545    let start = line_col_to_offset(
546        sql,
547        token.span.start.line as usize,
548        token.span.start.column as usize,
549    )?;
550    let end = line_col_to_offset(
551        sql,
552        token.span.end.line as usize,
553        token.span.end.column as usize,
554    )?;
555    Some((start, end))
556}
557
558fn is_trivia_token(token: &Token) -> bool {
559    matches!(
560        token,
561        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
562            | Token::Whitespace(Whitespace::SingleLineComment { .. })
563            | Token::Whitespace(Whitespace::MultiLineComment(_))
564    )
565}
566
567fn is_as_token(token: &Token) -> bool {
568    match token {
569        Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
570        _ => false,
571    }
572}
573
574fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
575    if line == 0 || column == 0 {
576        return None;
577    }
578
579    let mut current_line = 1usize;
580    let mut current_col = 1usize;
581
582    for (offset, ch) in sql.char_indices() {
583        if current_line == line && current_col == column {
584            return Some(offset);
585        }
586
587        if ch == '\n' {
588            current_line += 1;
589            current_col = 1;
590        } else {
591            current_col += 1;
592        }
593    }
594
595    if current_line == line && current_col == column {
596        return Some(sql.len());
597    }
598
599    None
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::linter::rule::with_active_dialect;
606    use crate::parser::parse_sql;
607    use crate::types::Dialect;
608
609    fn run(sql: &str) -> Vec<Issue> {
610        let statements = parse_sql(sql).expect("parse");
611        let rule = AliasingForbidSingleTable::default();
612        statements
613            .iter()
614            .enumerate()
615            .flat_map(|(index, statement)| {
616                rule.check(
617                    statement,
618                    &LintContext {
619                        sql,
620                        statement_range: 0..sql.len(),
621                        statement_index: index,
622                    },
623                )
624            })
625            .collect()
626    }
627
628    fn run_force_enabled_statementless_mssql(sql: &str) -> Vec<Issue> {
629        let synthetic = parse_sql("SELECT 1").expect("parse");
630        let config = LintConfig {
631            enabled: true,
632            disabled_rules: vec![],
633            rule_configs: std::collections::BTreeMap::from([(
634                "aliasing.forbid".to_string(),
635                serde_json::json!({"force_enable": true}),
636            )]),
637        };
638        let rule = AliasingForbidSingleTable::from_config(&config);
639        with_active_dialect(Dialect::Mssql, || {
640            rule.check(
641                &synthetic[0],
642                &LintContext {
643                    sql,
644                    statement_range: 0..sql.len(),
645                    statement_index: 0,
646                },
647            )
648        })
649    }
650
651    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
652        let autofix = issue.autofix.as_ref()?;
653        let mut out = sql.to_string();
654        let mut edits = autofix.edits.clone();
655        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
656        for edit in edits.into_iter().rev() {
657            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
658        }
659        Some(out)
660    }
661
662    fn run_force_enabled(sql: &str) -> Vec<Issue> {
663        let statements = parse_sql(sql).expect("parse");
664        let config = LintConfig {
665            enabled: true,
666            disabled_rules: vec![],
667            rule_configs: std::collections::BTreeMap::from([(
668                "aliasing.forbid".to_string(),
669                serde_json::json!({"force_enable": true}),
670            )]),
671        };
672        let rule = AliasingForbidSingleTable::from_config(&config);
673        statements
674            .iter()
675            .enumerate()
676            .flat_map(|(index, statement)| {
677                rule.check(
678                    statement,
679                    &LintContext {
680                        sql,
681                        statement_range: 0..sql.len(),
682                        statement_index: index,
683                    },
684                )
685            })
686            .collect()
687    }
688
689    #[test]
690    fn disabled_by_default() {
691        let issues = run("SELECT * FROM users u");
692        assert!(issues.is_empty());
693    }
694
695    #[test]
696    fn flags_single_table_alias_when_force_enabled() {
697        let issues = run_force_enabled("SELECT * FROM users u");
698        assert_eq!(issues.len(), 1);
699        assert_eq!(issues[0].code, issue_codes::LINT_AL_007);
700    }
701
702    #[test]
703    fn does_not_flag_single_table_without_alias() {
704        let issues = run_force_enabled("SELECT * FROM users");
705        assert!(issues.is_empty());
706    }
707
708    #[test]
709    fn flags_multi_source_query_when_force_enabled() {
710        let issues = run_force_enabled("SELECT * FROM users u JOIN orders o ON u.id = o.user_id");
711        assert_eq!(issues.len(), 2);
712    }
713
714    #[test]
715    fn allows_self_join_aliases() {
716        let issues = run_force_enabled("SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id");
717        assert!(issues.is_empty());
718    }
719
720    #[test]
721    fn flags_non_self_join_alias_in_self_join_scope() {
722        let issues = run_force_enabled(
723            "SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id JOIN orders o ON o.user_id = u1.id",
724        );
725        assert_eq!(issues.len(), 1);
726    }
727
728    #[test]
729    fn does_not_flag_derived_table_alias() {
730        let issues = run_force_enabled("SELECT * FROM (SELECT 1 AS id) sub");
731        assert!(issues.is_empty());
732    }
733
734    #[test]
735    fn flags_nested_single_table_alias() {
736        let issues = run_force_enabled("SELECT * FROM (SELECT * FROM users u) sub");
737        assert_eq!(issues.len(), 1);
738    }
739
740    #[test]
741    fn force_enable_false_disables_rule() {
742        let config = LintConfig {
743            enabled: true,
744            disabled_rules: vec![],
745            rule_configs: std::collections::BTreeMap::from([(
746                "aliasing.forbid".to_string(),
747                serde_json::json!({"force_enable": false}),
748            )]),
749        };
750        let rule = AliasingForbidSingleTable::from_config(&config);
751        let sql = "SELECT * FROM users u";
752        let statements = parse_sql(sql).expect("parse");
753        let issues = rule.check(
754            &statements[0],
755            &LintContext {
756                sql,
757                statement_range: 0..sql.len(),
758                statement_index: 0,
759            },
760        );
761        assert!(issues.is_empty());
762    }
763
764    #[test]
765    fn single_table_alias_autofix_removes_alias_and_replaces_refs() {
766        let sql = "SELECT u.id FROM users AS u";
767        let issues = run_force_enabled(sql);
768        assert_eq!(issues.len(), 1);
769        let autofix = issues[0].autofix.as_ref().expect("expected autofix");
770        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
771        // Should have: 1 delete (alias decl) + 1 replace (u.id → users.id)
772        assert!(
773            autofix.edits.len() >= 2,
774            "expected at least 2 edits, got: {:?}",
775            autofix.edits
776        );
777    }
778
779    #[test]
780    fn multi_table_alias_autofix() {
781        let sql = "SELECT u.id, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id";
782        let issues = run_force_enabled(sql);
783        assert_eq!(issues.len(), 2);
784        for issue in &issues {
785            assert!(issue.autofix.is_some(), "expected autofix on AL07 issue");
786        }
787    }
788
789    #[test]
790    fn statementless_tsql_create_table_as_alias_fallback_detects_and_fixes() {
791        let sql = "DECLARE @VariableE date = GETDATE()\n\nCREATE TABLE #TempTable\nAS\n(\n  Select ColumnD\n  from SchemaA.TableB AliasC\n  where ColumnD  >= @VariableE\n)\n";
792        let issues = run_force_enabled_statementless_mssql(sql);
793        assert_eq!(issues.len(), 1);
794        let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
795        assert_eq!(
796            fixed,
797            "DECLARE @VariableE date = GETDATE()\n\nCREATE TABLE #TempTable\nAS\n(\n  Select ColumnD\n  from SchemaA.TableB\n  where ColumnD  >= @VariableE\n)\n"
798        );
799    }
800}