Skip to main content

flowscope_core/linter/rules/
al_001.rs

1//! LINT_AL_001: Table alias style.
2//!
3//! SQLFluff parity: configurable table aliasing style (`explicit`/`implicit`).
4
5use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::{
9    CreateView, Expr, FromTable, Ident, Merge, Query, SetExpr, Statement, TableFactor,
10    TableWithJoins, Update, UpdateTableFromKind,
11};
12use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15enum AliasingPreference {
16    Explicit,
17    Implicit,
18}
19
20impl AliasingPreference {
21    fn from_config(config: &LintConfig, rule_code: &str) -> Self {
22        match config
23            .rule_option_str(rule_code, "aliasing")
24            .unwrap_or("explicit")
25            .to_ascii_lowercase()
26            .as_str()
27        {
28            "implicit" => Self::Implicit,
29            _ => Self::Explicit,
30        }
31    }
32
33    fn message(self) -> &'static str {
34        match self {
35            Self::Explicit => "Use explicit AS when aliasing tables.",
36            Self::Implicit => "Use implicit aliasing when aliasing tables (omit AS).",
37        }
38    }
39
40    fn violation(self, explicit_as: bool) -> bool {
41        match self {
42            Self::Explicit => !explicit_as,
43            Self::Implicit => explicit_as,
44        }
45    }
46}
47
48pub struct AliasingTableStyle {
49    aliasing: AliasingPreference,
50}
51
52impl AliasingTableStyle {
53    pub fn from_config(config: &LintConfig) -> Self {
54        Self {
55            aliasing: AliasingPreference::from_config(config, issue_codes::LINT_AL_001),
56        }
57    }
58}
59
60impl Default for AliasingTableStyle {
61    fn default() -> Self {
62        Self {
63            aliasing: AliasingPreference::Explicit,
64        }
65    }
66}
67
68impl LintRule for AliasingTableStyle {
69    fn code(&self) -> &'static str {
70        issue_codes::LINT_AL_001
71    }
72
73    fn name(&self) -> &'static str {
74        "Table alias style"
75    }
76
77    fn description(&self) -> &'static str {
78        "Implicit/explicit aliasing of table."
79    }
80
81    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
82        let mut issues = Vec::new();
83        let tokens =
84            tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
85
86        collect_table_aliases_in_statement(statement, &mut |alias| {
87            let Some(occurrence) = alias_occurrence_in_statement(alias, ctx, tokens.as_deref())
88            else {
89                return;
90            };
91
92            if !self.aliasing.violation(occurrence.explicit_as) {
93                return;
94            }
95
96            let mut issue = Issue::warning(issue_codes::LINT_AL_001, self.aliasing.message())
97                .with_statement(ctx.statement_index)
98                .with_span(ctx.span_from_statement_offset(occurrence.start, occurrence.end));
99            if let Some(edits) = autofix_edits_for_occurrence(occurrence, self.aliasing) {
100                issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
101            }
102
103            issues.push(issue);
104        });
105
106        issues
107    }
108}
109
110#[derive(Clone, Copy)]
111struct AliasOccurrence {
112    start: usize,
113    end: usize,
114    explicit_as: bool,
115    as_span: Option<Span>,
116    /// Whether there is whitespace before the alias position.
117    has_leading_whitespace: bool,
118}
119
120fn autofix_edits_for_occurrence(
121    occurrence: AliasOccurrence,
122    aliasing: AliasingPreference,
123) -> Option<Vec<IssuePatchEdit>> {
124    match aliasing {
125        AliasingPreference::Explicit if !occurrence.explicit_as => {
126            let insert = Span::new(occurrence.start, occurrence.start);
127            let replacement = if occurrence.has_leading_whitespace {
128                "AS "
129            } else {
130                " AS "
131            };
132            Some(vec![IssuePatchEdit::new(insert, replacement)])
133        }
134        AliasingPreference::Implicit if occurrence.explicit_as => {
135            let as_span = occurrence.as_span?;
136            // Replace " AS " (leading whitespace + AS keyword + trailing whitespace)
137            // with a single space to preserve separation between table name and alias.
138            let delete_end = occurrence.start;
139            Some(vec![IssuePatchEdit::new(
140                Span::new(as_span.start, delete_end),
141                " ",
142            )])
143        }
144        _ => None,
145    }
146}
147
148fn alias_occurrence_in_statement(
149    alias: &Ident,
150    ctx: &LintContext,
151    tokens: Option<&[LocatedToken]>,
152) -> Option<AliasOccurrence> {
153    let tokens = tokens?;
154
155    let abs_start = line_col_to_offset(
156        ctx.sql,
157        alias.span.start.line as usize,
158        alias.span.start.column as usize,
159    )?;
160    let abs_end = line_col_to_offset(
161        ctx.sql,
162        alias.span.end.line as usize,
163        alias.span.end.column as usize,
164    )?;
165
166    if abs_start < ctx.statement_range.start || abs_end > ctx.statement_range.end {
167        return None;
168    }
169
170    let rel_start = abs_start - ctx.statement_range.start;
171    let rel_end = abs_end - ctx.statement_range.start;
172    let (explicit_as, as_span) = explicit_as_before_alias_tokens(tokens, rel_start)?;
173    let has_leading_whitespace = has_whitespace_before(tokens, rel_start);
174    Some(AliasOccurrence {
175        start: rel_start,
176        end: rel_end,
177        explicit_as,
178        as_span,
179        has_leading_whitespace,
180    })
181}
182
183fn collect_table_aliases_in_statement<F: FnMut(&Ident)>(statement: &Statement, visitor: &mut F) {
184    match statement {
185        Statement::Query(query) => collect_table_aliases_in_query(query, visitor),
186        Statement::Insert(insert) => {
187            if let Some(source) = &insert.source {
188                collect_table_aliases_in_query(source, visitor);
189            }
190        }
191        Statement::CreateView(CreateView { query, .. }) => {
192            collect_table_aliases_in_query(query, visitor)
193        }
194        Statement::CreateTable(create) => {
195            if let Some(query) = &create.query {
196                collect_table_aliases_in_query(query, visitor);
197            }
198        }
199        Statement::Update(Update {
200            table,
201            from,
202            selection,
203            ..
204        }) => {
205            // SQLFluff does not flag the UPDATE target's own alias —
206            // only FROM/JOIN aliases in subqueries or PostgreSQL FROM clause.
207            // Visit joins on the target table but not the target table itself.
208            for join in &table.joins {
209                collect_table_aliases_in_table_factor(&join.relation, visitor);
210            }
211            if let Some(from) = from {
212                match from {
213                    UpdateTableFromKind::BeforeSet(tables)
214                    | UpdateTableFromKind::AfterSet(tables) => {
215                        for t in tables {
216                            collect_table_aliases_in_table_with_joins(t, visitor);
217                        }
218                    }
219                }
220            }
221            if let Some(selection) = selection {
222                collect_table_aliases_in_expr(selection, visitor);
223            }
224        }
225        Statement::Delete(delete) => {
226            match &delete.from {
227                FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => {
228                    for t in tables {
229                        collect_table_aliases_in_table_with_joins(t, visitor);
230                    }
231                }
232            }
233            if let Some(using) = &delete.using {
234                for t in using {
235                    collect_table_aliases_in_table_with_joins(t, visitor);
236                }
237            }
238            if let Some(selection) = &delete.selection {
239                collect_table_aliases_in_expr(selection, visitor);
240            }
241        }
242        Statement::Merge(Merge { table, source, .. }) => {
243            collect_table_aliases_in_table_factor(table, visitor);
244            collect_table_aliases_in_table_factor(source, visitor);
245        }
246        _ => {}
247    }
248}
249
250fn collect_table_aliases_in_query<F: FnMut(&Ident)>(query: &Query, visitor: &mut F) {
251    if let Some(with) = &query.with {
252        for cte in &with.cte_tables {
253            collect_table_aliases_in_query(&cte.query, visitor);
254        }
255    }
256
257    collect_table_aliases_in_set_expr(&query.body, visitor);
258}
259
260fn collect_table_aliases_in_set_expr<F: FnMut(&Ident)>(set_expr: &SetExpr, visitor: &mut F) {
261    match set_expr {
262        SetExpr::Select(select) => {
263            for table in &select.from {
264                collect_table_aliases_in_table_with_joins(table, visitor);
265                // Recurse into JOIN ON expressions for subquery aliases.
266                for join in &table.joins {
267                    if let Some(expr) = join_constraint_expr(&join.join_operator) {
268                        collect_table_aliases_in_expr(expr, visitor);
269                    }
270                }
271            }
272            if let Some(selection) = &select.selection {
273                collect_table_aliases_in_expr(selection, visitor);
274            }
275            if let Some(having) = &select.having {
276                collect_table_aliases_in_expr(having, visitor);
277            }
278            if let Some(qualify) = &select.qualify {
279                collect_table_aliases_in_expr(qualify, visitor);
280            }
281            // SELECT-list expressions (e.g. scalar subqueries).
282            for item in &select.projection {
283                match item {
284                    sqlparser::ast::SelectItem::UnnamedExpr(expr)
285                    | sqlparser::ast::SelectItem::ExprWithAlias { expr, .. } => {
286                        collect_table_aliases_in_expr(expr, visitor);
287                    }
288                    _ => {}
289                }
290            }
291        }
292        SetExpr::Query(query) => collect_table_aliases_in_query(query, visitor),
293        SetExpr::SetOperation { left, right, .. } => {
294            collect_table_aliases_in_set_expr(left, visitor);
295            collect_table_aliases_in_set_expr(right, visitor);
296        }
297        SetExpr::Insert(statement)
298        | SetExpr::Update(statement)
299        | SetExpr::Delete(statement)
300        | SetExpr::Merge(statement) => collect_table_aliases_in_statement(statement, visitor),
301        _ => {}
302    }
303}
304
305fn collect_table_aliases_in_table_with_joins<F: FnMut(&Ident)>(
306    table_with_joins: &TableWithJoins,
307    visitor: &mut F,
308) {
309    collect_table_aliases_in_table_factor(&table_with_joins.relation, visitor);
310    for join in &table_with_joins.joins {
311        collect_table_aliases_in_table_factor(&join.relation, visitor);
312    }
313}
314
315fn collect_table_aliases_in_table_factor<F: FnMut(&Ident)>(
316    table_factor: &TableFactor,
317    visitor: &mut F,
318) {
319    if let Some(alias) = table_factor_alias_ident(table_factor) {
320        visitor(alias);
321    }
322
323    match table_factor {
324        TableFactor::Derived { subquery, .. } => collect_table_aliases_in_query(subquery, visitor),
325        TableFactor::NestedJoin {
326            table_with_joins, ..
327        } => collect_table_aliases_in_table_with_joins(table_with_joins, visitor),
328        TableFactor::Pivot { table, .. }
329        | TableFactor::Unpivot { table, .. }
330        | TableFactor::MatchRecognize { table, .. } => {
331            collect_table_aliases_in_table_factor(table, visitor)
332        }
333        _ => {}
334    }
335}
336
337/// Recursively visits expression trees to collect table aliases from subqueries.
338fn collect_table_aliases_in_expr<F: FnMut(&Ident)>(expr: &Expr, visitor: &mut F) {
339    match expr {
340        Expr::Subquery(query)
341        | Expr::Exists {
342            subquery: query, ..
343        } => {
344            collect_table_aliases_in_query(query, visitor);
345        }
346        Expr::InSubquery {
347            expr: inner,
348            subquery,
349            ..
350        } => {
351            collect_table_aliases_in_expr(inner, visitor);
352            collect_table_aliases_in_query(subquery, visitor);
353        }
354        Expr::BinaryOp { left, right, .. } => {
355            collect_table_aliases_in_expr(left, visitor);
356            collect_table_aliases_in_expr(right, visitor);
357        }
358        Expr::UnaryOp { expr: inner, .. }
359        | Expr::Nested(inner)
360        | Expr::Cast { expr: inner, .. } => {
361            collect_table_aliases_in_expr(inner, visitor);
362        }
363        Expr::Case {
364            operand,
365            conditions,
366            else_result,
367            ..
368        } => {
369            if let Some(op) = operand {
370                collect_table_aliases_in_expr(op, visitor);
371            }
372            for cw in conditions {
373                collect_table_aliases_in_expr(&cw.condition, visitor);
374                collect_table_aliases_in_expr(&cw.result, visitor);
375            }
376            if let Some(el) = else_result {
377                collect_table_aliases_in_expr(el, visitor);
378            }
379        }
380        Expr::Function(func) => {
381            if let sqlparser::ast::FunctionArguments::List(arg_list) = &func.args {
382                for arg in &arg_list.args {
383                    match arg {
384                        sqlparser::ast::FunctionArg::Unnamed(
385                            sqlparser::ast::FunctionArgExpr::Expr(e),
386                        )
387                        | sqlparser::ast::FunctionArg::Named {
388                            arg: sqlparser::ast::FunctionArgExpr::Expr(e),
389                            ..
390                        } => collect_table_aliases_in_expr(e, visitor),
391                        _ => {}
392                    }
393                }
394            } else if let sqlparser::ast::FunctionArguments::Subquery(query) = &func.args {
395                collect_table_aliases_in_query(query, visitor);
396            }
397        }
398        Expr::Between {
399            expr: inner,
400            low,
401            high,
402            ..
403        } => {
404            collect_table_aliases_in_expr(inner, visitor);
405            collect_table_aliases_in_expr(low, visitor);
406            collect_table_aliases_in_expr(high, visitor);
407        }
408        Expr::InList {
409            expr: inner, list, ..
410        } => {
411            collect_table_aliases_in_expr(inner, visitor);
412            for item in list {
413                collect_table_aliases_in_expr(item, visitor);
414            }
415        }
416        Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
417            collect_table_aliases_in_expr(inner, visitor);
418        }
419        _ => {}
420    }
421}
422
423fn join_constraint_expr(op: &sqlparser::ast::JoinOperator) -> Option<&Expr> {
424    use sqlparser::ast::{JoinConstraint, JoinOperator};
425    let constraint = match op {
426        JoinOperator::Join(c)
427        | JoinOperator::Inner(c)
428        | JoinOperator::Left(c)
429        | JoinOperator::LeftOuter(c)
430        | JoinOperator::Right(c)
431        | JoinOperator::RightOuter(c)
432        | JoinOperator::FullOuter(c)
433        | JoinOperator::CrossJoin(c)
434        | JoinOperator::Semi(c)
435        | JoinOperator::LeftSemi(c)
436        | JoinOperator::RightSemi(c)
437        | JoinOperator::Anti(c)
438        | JoinOperator::LeftAnti(c)
439        | JoinOperator::RightAnti(c)
440        | JoinOperator::StraightJoin(c) => c,
441        JoinOperator::AsOf { constraint, .. } => constraint,
442        JoinOperator::CrossApply | JoinOperator::OuterApply => return None,
443    };
444    if let JoinConstraint::On(expr) = constraint {
445        Some(expr)
446    } else {
447        None
448    }
449}
450
451fn table_factor_alias_ident(table_factor: &TableFactor) -> Option<&Ident> {
452    let alias = match table_factor {
453        TableFactor::Table { alias, .. }
454        | TableFactor::Derived { alias, .. }
455        | TableFactor::TableFunction { alias, .. }
456        | TableFactor::Function { alias, .. }
457        | TableFactor::UNNEST { alias, .. }
458        | TableFactor::JsonTable { alias, .. }
459        | TableFactor::OpenJsonTable { alias, .. }
460        | TableFactor::NestedJoin { alias, .. }
461        | TableFactor::Pivot { alias, .. }
462        | TableFactor::Unpivot { alias, .. }
463        | TableFactor::MatchRecognize { alias, .. }
464        | TableFactor::XmlTable { alias, .. }
465        | TableFactor::SemanticView { alias, .. } => alias.as_ref(),
466    }?;
467
468    Some(&alias.name)
469}
470
471fn explicit_as_before_alias_tokens(
472    tokens: &[LocatedToken],
473    alias_start: usize,
474) -> Option<(bool, Option<Span>)> {
475    let token = tokens
476        .iter()
477        .rev()
478        .find(|token| token.end <= alias_start && !is_trivia_token(&token.token))?;
479    if is_as_token(&token.token) {
480        // Look for leading whitespace before AS to include in the span.
481        let leading_ws_start = tokens
482            .iter()
483            .rev()
484            .find(|t| t.end <= token.start && !is_trivia_token(&t.token))
485            .map(|t| t.end)
486            .unwrap_or(token.start);
487        Some((true, Some(Span::new(leading_ws_start, token.end))))
488    } else {
489        Some((false, None))
490    }
491}
492
493/// Checks if there is whitespace immediately before the given position.
494fn has_whitespace_before(tokens: &[LocatedToken], pos: usize) -> bool {
495    tokens
496        .iter()
497        .rev()
498        .find(|t| t.end <= pos)
499        .is_some_and(|t| is_trivia_token(&t.token))
500}
501
502fn is_as_token(token: &Token) -> bool {
503    match token {
504        Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
505        _ => false,
506    }
507}
508
509#[derive(Clone)]
510struct LocatedToken {
511    token: Token,
512    start: usize,
513    end: usize,
514}
515
516fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
517    let dialect = dialect.to_sqlparser_dialect();
518    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
519    let tokens = tokenizer.tokenize_with_location().ok()?;
520
521    let mut out = Vec::with_capacity(tokens.len());
522    for token in tokens {
523        let (start, end) = token_with_span_offsets(sql, &token)?;
524        out.push(LocatedToken {
525            token: token.token,
526            start,
527            end,
528        });
529    }
530    Some(out)
531}
532
533fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
534    let statement_start = ctx.statement_range.start;
535    ctx.with_document_tokens(|tokens| {
536        if tokens.is_empty() {
537            return None;
538        }
539
540        Some(
541            tokens
542                .iter()
543                .filter_map(|token| {
544                    let (start, end) = token_with_span_offsets(ctx.sql, token)?;
545                    if start < ctx.statement_range.start || end > ctx.statement_range.end {
546                        return None;
547                    }
548                    Some(LocatedToken {
549                        token: token.token.clone(),
550                        start: start - statement_start,
551                        end: end - statement_start,
552                    })
553                })
554                .collect::<Vec<_>>(),
555        )
556    })
557}
558
559fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
560    let start = line_col_to_offset(
561        sql,
562        token.span.start.line as usize,
563        token.span.start.column as usize,
564    )?;
565    let end = line_col_to_offset(
566        sql,
567        token.span.end.line as usize,
568        token.span.end.column as usize,
569    )?;
570    Some((start, end))
571}
572
573fn is_trivia_token(token: &Token) -> bool {
574    matches!(
575        token,
576        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
577            | Token::Whitespace(Whitespace::SingleLineComment { .. })
578            | Token::Whitespace(Whitespace::MultiLineComment(_))
579    )
580}
581
582fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
583    if line == 0 || column == 0 {
584        return None;
585    }
586
587    let mut current_line = 1usize;
588    let mut current_col = 1usize;
589
590    for (offset, ch) in sql.char_indices() {
591        if current_line == line && current_col == column {
592            return Some(offset);
593        }
594
595        if ch == '\n' {
596            current_line += 1;
597            current_col = 1;
598        } else {
599            current_col += 1;
600        }
601    }
602
603    if current_line == line && current_col == column {
604        return Some(sql.len());
605    }
606
607    None
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::{
614        parser::{parse_sql, parse_sql_with_dialect},
615        types::IssueAutofixApplicability,
616        Dialect,
617    };
618
619    fn run_with_rule(sql: &str, rule: AliasingTableStyle) -> Vec<Issue> {
620        let stmts = parse_sql(sql).expect("parse");
621        stmts
622            .iter()
623            .enumerate()
624            .flat_map(|(index, stmt)| {
625                rule.check(
626                    stmt,
627                    &LintContext {
628                        sql,
629                        statement_range: 0..sql.len(),
630                        statement_index: index,
631                    },
632                )
633            })
634            .collect()
635    }
636
637    fn run(sql: &str) -> Vec<Issue> {
638        run_with_rule(sql, AliasingTableStyle::default())
639    }
640
641    #[test]
642    fn flags_implicit_table_aliases() {
643        let issues = run("select * from users u join orders o on u.id = o.user_id");
644        assert_eq!(issues.len(), 2);
645        assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
646    }
647
648    #[test]
649    fn allows_explicit_as_table_aliases() {
650        let issues = run("select * from users as u join orders as o on u.id = o.user_id");
651        assert!(issues.is_empty());
652    }
653
654    #[test]
655    fn flags_explicit_aliases_when_implicit_policy_requested() {
656        let config = LintConfig {
657            enabled: true,
658            disabled_rules: vec![],
659            rule_configs: std::collections::BTreeMap::from([(
660                "LINT_AL_001".to_string(),
661                serde_json::json!({"aliasing": "implicit"}),
662            )]),
663        };
664        let issues = run_with_rule(
665            "select * from users as u join orders as o on u.id = o.user_id",
666            AliasingTableStyle::from_config(&config),
667        );
668        assert_eq!(issues.len(), 2);
669    }
670
671    #[test]
672    fn flags_implicit_derived_table_alias() {
673        let issues = run("select * from (select 1) d");
674        assert_eq!(issues.len(), 1);
675        assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
676    }
677
678    #[test]
679    fn flags_implicit_merge_aliases_in_bigquery() {
680        let sql = "MERGE dataset.inventory t USING dataset.newarrivals s ON t.product = s.product WHEN MATCHED THEN UPDATE SET quantity = t.quantity + s.quantity";
681        let statements = parse_sql_with_dialect(sql, Dialect::Bigquery).expect("parse");
682        let issues = statements
683            .iter()
684            .enumerate()
685            .flat_map(|(index, stmt)| {
686                AliasingTableStyle::default().check(
687                    stmt,
688                    &LintContext {
689                        sql,
690                        statement_range: 0..sql.len(),
691                        statement_index: index,
692                    },
693                )
694            })
695            .collect::<Vec<_>>();
696        assert_eq!(issues.len(), 2);
697        assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
698    }
699
700    #[test]
701    fn explicit_mode_emits_safe_insert_as_autofix_patch() {
702        let sql = "select * from users u";
703        let issues = run(sql);
704        assert_eq!(issues.len(), 1);
705
706        let autofix = issues[0]
707            .autofix
708            .as_ref()
709            .expect("expected AL001 core autofix metadata in explicit mode");
710        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
711        assert_eq!(autofix.edits.len(), 1);
712        assert_eq!(autofix.edits[0].replacement, "AS ");
713        assert_eq!(autofix.edits[0].span.start, autofix.edits[0].span.end);
714    }
715
716    #[test]
717    fn implicit_mode_emits_safe_remove_as_autofix_patch() {
718        let config = LintConfig {
719            enabled: true,
720            disabled_rules: vec![],
721            rule_configs: std::collections::BTreeMap::from([(
722                "LINT_AL_001".to_string(),
723                serde_json::json!({"aliasing": "implicit"}),
724            )]),
725        };
726        let rule = AliasingTableStyle::from_config(&config);
727        let sql = "select * from users as u";
728        let issues = run_with_rule(sql, rule);
729        assert_eq!(issues.len(), 1);
730
731        let autofix = issues[0]
732            .autofix
733            .as_ref()
734            .expect("expected AL001 core autofix metadata in implicit mode");
735        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
736        assert_eq!(autofix.edits.len(), 1);
737        assert_eq!(autofix.edits[0].replacement, " ");
738        // Span should cover " as " (leading whitespace + AS keyword + trailing whitespace).
739        assert_eq!(
740            &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
741            " as "
742        );
743    }
744
745    #[test]
746    fn flags_implicit_aliases_in_update_where_exists_subquery() {
747        let sql = "UPDATE t SET x = 1 WHERE EXISTS (SELECT 1 FROM users u)";
748        let issues = run(sql);
749        // `u` is implicit alias inside EXISTS subquery.
750        assert_eq!(issues.len(), 1);
751        assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
752    }
753
754    #[test]
755    fn skips_update_target_table_alias() {
756        // SQLFluff does not flag the UPDATE target's own alias.
757        let sql = "UPDATE users u SET u.x = 1";
758        let issues = run(sql);
759        assert!(issues.is_empty());
760    }
761
762    #[test]
763    fn flags_implicit_aliases_in_delete_where_exists() {
764        let sql =
765            "DELETE FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)";
766        let issues = run(sql);
767        // `u` in DELETE FROM + `o` in EXISTS subquery.
768        assert_eq!(issues.len(), 2);
769    }
770}