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