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    Expr, FromTable, Ident, Query, SetExpr, Statement, TableFactor, TableWithJoins,
10    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 { query, .. } => collect_table_aliases_in_query(query, visitor),
192        Statement::CreateTable(create) => {
193            if let Some(query) = &create.query {
194                collect_table_aliases_in_query(query, visitor);
195            }
196        }
197        Statement::Update {
198            table,
199            from,
200            selection,
201            ..
202        } => {
203            // SQLFluff does not flag the UPDATE target's own alias —
204            // only FROM/JOIN aliases in subqueries or PostgreSQL FROM clause.
205            // Visit joins on the target table but not the target table itself.
206            for join in &table.joins {
207                collect_table_aliases_in_table_factor(&join.relation, visitor);
208            }
209            if let Some(from) = from {
210                match from {
211                    UpdateTableFromKind::BeforeSet(tables)
212                    | UpdateTableFromKind::AfterSet(tables) => {
213                        for t in tables {
214                            collect_table_aliases_in_table_with_joins(t, visitor);
215                        }
216                    }
217                }
218            }
219            if let Some(selection) = selection {
220                collect_table_aliases_in_expr(selection, visitor);
221            }
222        }
223        Statement::Delete(delete) => {
224            match &delete.from {
225                FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => {
226                    for t in tables {
227                        collect_table_aliases_in_table_with_joins(t, visitor);
228                    }
229                }
230            }
231            if let Some(using) = &delete.using {
232                for t in using {
233                    collect_table_aliases_in_table_with_joins(t, visitor);
234                }
235            }
236            if let Some(selection) = &delete.selection {
237                collect_table_aliases_in_expr(selection, visitor);
238            }
239        }
240        Statement::Merge { table, source, .. } => {
241            collect_table_aliases_in_table_factor(table, visitor);
242            collect_table_aliases_in_table_factor(source, visitor);
243        }
244        _ => {}
245    }
246}
247
248fn collect_table_aliases_in_query<F: FnMut(&Ident)>(query: &Query, visitor: &mut F) {
249    if let Some(with) = &query.with {
250        for cte in &with.cte_tables {
251            collect_table_aliases_in_query(&cte.query, visitor);
252        }
253    }
254
255    collect_table_aliases_in_set_expr(&query.body, visitor);
256}
257
258fn collect_table_aliases_in_set_expr<F: FnMut(&Ident)>(set_expr: &SetExpr, visitor: &mut F) {
259    match set_expr {
260        SetExpr::Select(select) => {
261            for table in &select.from {
262                collect_table_aliases_in_table_with_joins(table, visitor);
263                // Recurse into JOIN ON expressions for subquery aliases.
264                for join in &table.joins {
265                    if let Some(expr) = join_constraint_expr(&join.join_operator) {
266                        collect_table_aliases_in_expr(expr, visitor);
267                    }
268                }
269            }
270            if let Some(selection) = &select.selection {
271                collect_table_aliases_in_expr(selection, visitor);
272            }
273            if let Some(having) = &select.having {
274                collect_table_aliases_in_expr(having, visitor);
275            }
276            if let Some(qualify) = &select.qualify {
277                collect_table_aliases_in_expr(qualify, visitor);
278            }
279            // SELECT-list expressions (e.g. scalar subqueries).
280            for item in &select.projection {
281                match item {
282                    sqlparser::ast::SelectItem::UnnamedExpr(expr)
283                    | sqlparser::ast::SelectItem::ExprWithAlias { expr, .. } => {
284                        collect_table_aliases_in_expr(expr, visitor);
285                    }
286                    _ => {}
287                }
288            }
289        }
290        SetExpr::Query(query) => collect_table_aliases_in_query(query, visitor),
291        SetExpr::SetOperation { left, right, .. } => {
292            collect_table_aliases_in_set_expr(left, visitor);
293            collect_table_aliases_in_set_expr(right, visitor);
294        }
295        SetExpr::Insert(statement)
296        | SetExpr::Update(statement)
297        | SetExpr::Delete(statement)
298        | SetExpr::Merge(statement) => collect_table_aliases_in_statement(statement, visitor),
299        _ => {}
300    }
301}
302
303fn collect_table_aliases_in_table_with_joins<F: FnMut(&Ident)>(
304    table_with_joins: &TableWithJoins,
305    visitor: &mut F,
306) {
307    collect_table_aliases_in_table_factor(&table_with_joins.relation, visitor);
308    for join in &table_with_joins.joins {
309        collect_table_aliases_in_table_factor(&join.relation, visitor);
310    }
311}
312
313fn collect_table_aliases_in_table_factor<F: FnMut(&Ident)>(
314    table_factor: &TableFactor,
315    visitor: &mut F,
316) {
317    if let Some(alias) = table_factor_alias_ident(table_factor) {
318        visitor(alias);
319    }
320
321    match table_factor {
322        TableFactor::Derived { subquery, .. } => collect_table_aliases_in_query(subquery, visitor),
323        TableFactor::NestedJoin {
324            table_with_joins, ..
325        } => collect_table_aliases_in_table_with_joins(table_with_joins, visitor),
326        TableFactor::Pivot { table, .. }
327        | TableFactor::Unpivot { table, .. }
328        | TableFactor::MatchRecognize { table, .. } => {
329            collect_table_aliases_in_table_factor(table, visitor)
330        }
331        _ => {}
332    }
333}
334
335/// Recursively visits expression trees to collect table aliases from subqueries.
336fn collect_table_aliases_in_expr<F: FnMut(&Ident)>(expr: &Expr, visitor: &mut F) {
337    match expr {
338        Expr::Subquery(query)
339        | Expr::Exists {
340            subquery: query, ..
341        } => {
342            collect_table_aliases_in_query(query, visitor);
343        }
344        Expr::InSubquery {
345            expr: inner,
346            subquery,
347            ..
348        } => {
349            collect_table_aliases_in_expr(inner, visitor);
350            collect_table_aliases_in_query(subquery, visitor);
351        }
352        Expr::BinaryOp { left, right, .. } => {
353            collect_table_aliases_in_expr(left, visitor);
354            collect_table_aliases_in_expr(right, visitor);
355        }
356        Expr::UnaryOp { expr: inner, .. }
357        | Expr::Nested(inner)
358        | Expr::Cast { expr: inner, .. } => {
359            collect_table_aliases_in_expr(inner, visitor);
360        }
361        Expr::Case {
362            operand,
363            conditions,
364            else_result,
365            ..
366        } => {
367            if let Some(op) = operand {
368                collect_table_aliases_in_expr(op, visitor);
369            }
370            for cw in conditions {
371                collect_table_aliases_in_expr(&cw.condition, visitor);
372                collect_table_aliases_in_expr(&cw.result, visitor);
373            }
374            if let Some(el) = else_result {
375                collect_table_aliases_in_expr(el, visitor);
376            }
377        }
378        Expr::Function(func) => {
379            if let sqlparser::ast::FunctionArguments::List(arg_list) = &func.args {
380                for arg in &arg_list.args {
381                    match arg {
382                        sqlparser::ast::FunctionArg::Unnamed(
383                            sqlparser::ast::FunctionArgExpr::Expr(e),
384                        )
385                        | sqlparser::ast::FunctionArg::Named {
386                            arg: sqlparser::ast::FunctionArgExpr::Expr(e),
387                            ..
388                        } => collect_table_aliases_in_expr(e, visitor),
389                        _ => {}
390                    }
391                }
392            } else if let sqlparser::ast::FunctionArguments::Subquery(query) = &func.args {
393                collect_table_aliases_in_query(query, visitor);
394            }
395        }
396        Expr::Between {
397            expr: inner,
398            low,
399            high,
400            ..
401        } => {
402            collect_table_aliases_in_expr(inner, visitor);
403            collect_table_aliases_in_expr(low, visitor);
404            collect_table_aliases_in_expr(high, visitor);
405        }
406        Expr::InList {
407            expr: inner, list, ..
408        } => {
409            collect_table_aliases_in_expr(inner, visitor);
410            for item in list {
411                collect_table_aliases_in_expr(item, visitor);
412            }
413        }
414        Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
415            collect_table_aliases_in_expr(inner, visitor);
416        }
417        _ => {}
418    }
419}
420
421fn join_constraint_expr(op: &sqlparser::ast::JoinOperator) -> Option<&Expr> {
422    use sqlparser::ast::{JoinConstraint, JoinOperator};
423    let constraint = match op {
424        JoinOperator::Join(c)
425        | JoinOperator::Inner(c)
426        | JoinOperator::Left(c)
427        | JoinOperator::LeftOuter(c)
428        | JoinOperator::Right(c)
429        | JoinOperator::RightOuter(c)
430        | JoinOperator::FullOuter(c)
431        | JoinOperator::CrossJoin(c)
432        | JoinOperator::Semi(c)
433        | JoinOperator::LeftSemi(c)
434        | JoinOperator::RightSemi(c)
435        | JoinOperator::Anti(c)
436        | JoinOperator::LeftAnti(c)
437        | JoinOperator::RightAnti(c)
438        | JoinOperator::StraightJoin(c) => c,
439        JoinOperator::AsOf { constraint, .. } => constraint,
440        JoinOperator::CrossApply | JoinOperator::OuterApply => return None,
441    };
442    if let JoinConstraint::On(expr) = constraint {
443        Some(expr)
444    } else {
445        None
446    }
447}
448
449fn table_factor_alias_ident(table_factor: &TableFactor) -> Option<&Ident> {
450    let alias = match table_factor {
451        TableFactor::Table { alias, .. }
452        | TableFactor::Derived { alias, .. }
453        | TableFactor::TableFunction { alias, .. }
454        | TableFactor::Function { alias, .. }
455        | TableFactor::UNNEST { alias, .. }
456        | TableFactor::JsonTable { alias, .. }
457        | TableFactor::OpenJsonTable { alias, .. }
458        | TableFactor::NestedJoin { alias, .. }
459        | TableFactor::Pivot { alias, .. }
460        | TableFactor::Unpivot { alias, .. }
461        | TableFactor::MatchRecognize { alias, .. }
462        | TableFactor::XmlTable { alias, .. }
463        | TableFactor::SemanticView { alias, .. } => alias.as_ref(),
464    }?;
465
466    Some(&alias.name)
467}
468
469fn explicit_as_before_alias_tokens(
470    tokens: &[LocatedToken],
471    alias_start: usize,
472) -> Option<(bool, Option<Span>)> {
473    let token = tokens
474        .iter()
475        .rev()
476        .find(|token| token.end <= alias_start && !is_trivia_token(&token.token))?;
477    if is_as_token(&token.token) {
478        // Look for leading whitespace before AS to include in the span.
479        let leading_ws_start = tokens
480            .iter()
481            .rev()
482            .find(|t| t.end <= token.start && !is_trivia_token(&t.token))
483            .map(|t| t.end)
484            .unwrap_or(token.start);
485        Some((true, Some(Span::new(leading_ws_start, token.end))))
486    } else {
487        Some((false, None))
488    }
489}
490
491/// Checks if there is whitespace immediately before the given position.
492fn has_whitespace_before(tokens: &[LocatedToken], pos: usize) -> bool {
493    tokens
494        .iter()
495        .rev()
496        .find(|t| t.end <= pos)
497        .is_some_and(|t| is_trivia_token(&t.token))
498}
499
500fn is_as_token(token: &Token) -> bool {
501    match token {
502        Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
503        _ => false,
504    }
505}
506
507#[derive(Clone)]
508struct LocatedToken {
509    token: Token,
510    start: usize,
511    end: usize,
512}
513
514fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
515    let dialect = dialect.to_sqlparser_dialect();
516    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
517    let tokens = tokenizer.tokenize_with_location().ok()?;
518
519    let mut out = Vec::with_capacity(tokens.len());
520    for token in tokens {
521        let (start, end) = token_with_span_offsets(sql, &token)?;
522        out.push(LocatedToken {
523            token: token.token,
524            start,
525            end,
526        });
527    }
528    Some(out)
529}
530
531fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
532    let statement_start = ctx.statement_range.start;
533    ctx.with_document_tokens(|tokens| {
534        if tokens.is_empty() {
535            return None;
536        }
537
538        Some(
539            tokens
540                .iter()
541                .filter_map(|token| {
542                    let (start, end) = token_with_span_offsets(ctx.sql, token)?;
543                    if start < ctx.statement_range.start || end > ctx.statement_range.end {
544                        return None;
545                    }
546                    Some(LocatedToken {
547                        token: token.token.clone(),
548                        start: start - statement_start,
549                        end: end - statement_start,
550                    })
551                })
552                .collect::<Vec<_>>(),
553        )
554    })
555}
556
557fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
558    let start = line_col_to_offset(
559        sql,
560        token.span.start.line as usize,
561        token.span.start.column as usize,
562    )?;
563    let end = line_col_to_offset(
564        sql,
565        token.span.end.line as usize,
566        token.span.end.column as usize,
567    )?;
568    Some((start, end))
569}
570
571fn is_trivia_token(token: &Token) -> bool {
572    matches!(
573        token,
574        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
575            | Token::Whitespace(Whitespace::SingleLineComment { .. })
576            | Token::Whitespace(Whitespace::MultiLineComment(_))
577    )
578}
579
580fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
581    if line == 0 || column == 0 {
582        return None;
583    }
584
585    let mut current_line = 1usize;
586    let mut current_col = 1usize;
587
588    for (offset, ch) in sql.char_indices() {
589        if current_line == line && current_col == column {
590            return Some(offset);
591        }
592
593        if ch == '\n' {
594            current_line += 1;
595            current_col = 1;
596        } else {
597            current_col += 1;
598        }
599    }
600
601    if current_line == line && current_col == column {
602        return Some(sql.len());
603    }
604
605    None
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::{
612        parser::{parse_sql, parse_sql_with_dialect},
613        types::IssueAutofixApplicability,
614        Dialect,
615    };
616
617    fn run_with_rule(sql: &str, rule: AliasingTableStyle) -> Vec<Issue> {
618        let stmts = parse_sql(sql).expect("parse");
619        stmts
620            .iter()
621            .enumerate()
622            .flat_map(|(index, stmt)| {
623                rule.check(
624                    stmt,
625                    &LintContext {
626                        sql,
627                        statement_range: 0..sql.len(),
628                        statement_index: index,
629                    },
630                )
631            })
632            .collect()
633    }
634
635    fn run(sql: &str) -> Vec<Issue> {
636        run_with_rule(sql, AliasingTableStyle::default())
637    }
638
639    #[test]
640    fn flags_implicit_table_aliases() {
641        let issues = run("select * from users u join orders o on u.id = o.user_id");
642        assert_eq!(issues.len(), 2);
643        assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
644    }
645
646    #[test]
647    fn allows_explicit_as_table_aliases() {
648        let issues = run("select * from users as u join orders as o on u.id = o.user_id");
649        assert!(issues.is_empty());
650    }
651
652    #[test]
653    fn flags_explicit_aliases_when_implicit_policy_requested() {
654        let config = LintConfig {
655            enabled: true,
656            disabled_rules: vec![],
657            rule_configs: std::collections::BTreeMap::from([(
658                "LINT_AL_001".to_string(),
659                serde_json::json!({"aliasing": "implicit"}),
660            )]),
661        };
662        let issues = run_with_rule(
663            "select * from users as u join orders as o on u.id = o.user_id",
664            AliasingTableStyle::from_config(&config),
665        );
666        assert_eq!(issues.len(), 2);
667    }
668
669    #[test]
670    fn flags_implicit_derived_table_alias() {
671        let issues = run("select * from (select 1) d");
672        assert_eq!(issues.len(), 1);
673        assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
674    }
675
676    #[test]
677    fn flags_implicit_merge_aliases_in_bigquery() {
678        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";
679        let statements = parse_sql_with_dialect(sql, Dialect::Bigquery).expect("parse");
680        let issues = statements
681            .iter()
682            .enumerate()
683            .flat_map(|(index, stmt)| {
684                AliasingTableStyle::default().check(
685                    stmt,
686                    &LintContext {
687                        sql,
688                        statement_range: 0..sql.len(),
689                        statement_index: index,
690                    },
691                )
692            })
693            .collect::<Vec<_>>();
694        assert_eq!(issues.len(), 2);
695        assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
696    }
697
698    #[test]
699    fn explicit_mode_emits_safe_insert_as_autofix_patch() {
700        let sql = "select * from users u";
701        let issues = run(sql);
702        assert_eq!(issues.len(), 1);
703
704        let autofix = issues[0]
705            .autofix
706            .as_ref()
707            .expect("expected AL001 core autofix metadata in explicit mode");
708        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
709        assert_eq!(autofix.edits.len(), 1);
710        assert_eq!(autofix.edits[0].replacement, "AS ");
711        assert_eq!(autofix.edits[0].span.start, autofix.edits[0].span.end);
712    }
713
714    #[test]
715    fn implicit_mode_emits_safe_remove_as_autofix_patch() {
716        let config = LintConfig {
717            enabled: true,
718            disabled_rules: vec![],
719            rule_configs: std::collections::BTreeMap::from([(
720                "LINT_AL_001".to_string(),
721                serde_json::json!({"aliasing": "implicit"}),
722            )]),
723        };
724        let rule = AliasingTableStyle::from_config(&config);
725        let sql = "select * from users as u";
726        let issues = run_with_rule(sql, rule);
727        assert_eq!(issues.len(), 1);
728
729        let autofix = issues[0]
730            .autofix
731            .as_ref()
732            .expect("expected AL001 core autofix metadata in implicit mode");
733        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
734        assert_eq!(autofix.edits.len(), 1);
735        assert_eq!(autofix.edits[0].replacement, " ");
736        // Span should cover " as " (leading whitespace + AS keyword + trailing whitespace).
737        assert_eq!(
738            &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
739            " as "
740        );
741    }
742
743    #[test]
744    fn flags_implicit_aliases_in_update_where_exists_subquery() {
745        let sql = "UPDATE t SET x = 1 WHERE EXISTS (SELECT 1 FROM users u)";
746        let issues = run(sql);
747        // `u` is implicit alias inside EXISTS subquery.
748        assert_eq!(issues.len(), 1);
749        assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
750    }
751
752    #[test]
753    fn skips_update_target_table_alias() {
754        // SQLFluff does not flag the UPDATE target's own alias.
755        let sql = "UPDATE users u SET u.x = 1";
756        let issues = run(sql);
757        assert!(issues.is_empty());
758    }
759
760    #[test]
761    fn flags_implicit_aliases_in_delete_where_exists() {
762        let sql =
763            "DELETE FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)";
764        let issues = run(sql);
765        // `u` in DELETE FROM + `o` in EXISTS subquery.
766        assert_eq!(issues.len(), 2);
767    }
768}