Skip to main content

flowscope_core/linter/rules/
al_009.rs

1//! LINT_AL_009: Self alias column.
2//!
3//! SQLFluff AL09 parity: avoid aliasing a column to its own name.
4
5use crate::generated::NormalizationStrategy;
6use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
9use regex::Regex;
10use sqlparser::ast::{Expr, Ident, SelectItem, Statement};
11use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
12
13use super::semantic_helpers::visit_selects_in_statement;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16enum AliasCaseCheck {
17    Dialect,
18    CaseInsensitive,
19    QuotedCsNakedUpper,
20    QuotedCsNakedLower,
21    CaseSensitive,
22}
23
24impl AliasCaseCheck {
25    fn from_config(config: &LintConfig) -> Self {
26        match config
27            .rule_option_str(issue_codes::LINT_AL_009, "alias_case_check")
28            .unwrap_or("dialect")
29            .to_ascii_lowercase()
30            .as_str()
31        {
32            "case_insensitive" => Self::CaseInsensitive,
33            "quoted_cs_naked_upper" => Self::QuotedCsNakedUpper,
34            "quoted_cs_naked_lower" => Self::QuotedCsNakedLower,
35            "case_sensitive" => Self::CaseSensitive,
36            _ => Self::Dialect,
37        }
38    }
39}
40
41#[derive(Clone, Copy, Debug)]
42struct NameRef<'a> {
43    name: &'a str,
44    quoted: bool,
45}
46
47pub struct AliasingSelfAliasColumn {
48    alias_case_check: AliasCaseCheck,
49}
50
51impl AliasingSelfAliasColumn {
52    pub fn from_config(config: &LintConfig) -> Self {
53        Self {
54            alias_case_check: AliasCaseCheck::from_config(config),
55        }
56    }
57}
58
59impl Default for AliasingSelfAliasColumn {
60    fn default() -> Self {
61        Self {
62            alias_case_check: AliasCaseCheck::Dialect,
63        }
64    }
65}
66
67impl LintRule for AliasingSelfAliasColumn {
68    fn code(&self) -> &'static str {
69        issue_codes::LINT_AL_009
70    }
71
72    fn name(&self) -> &'static str {
73        "Self alias column"
74    }
75
76    fn description(&self) -> &'static str {
77        "Column aliases should not alias to itself, i.e. self-alias."
78    }
79
80    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
81        let mut violating_aliases = Vec::new();
82
83        // Resolve Dialect mode to the concrete normalization strategy at check time
84        // so the matching logic uses the actual dialect rules.
85        let strategy = match self.alias_case_check {
86            AliasCaseCheck::Dialect => Some(ctx.dialect().normalization_strategy()),
87            _ => None,
88        };
89
90        visit_selects_in_statement(statement, &mut |select| {
91            for item in &select.projection {
92                let SelectItem::ExprWithAlias { expr, alias } = item else {
93                    continue;
94                };
95
96                if aliases_expression_to_itself(expr, alias, self.alias_case_check, strategy) {
97                    violating_aliases.push(alias.clone());
98                }
99            }
100        });
101        let violation_count = violating_aliases.len();
102        let mut autofix_candidates = al009_autofix_candidates_for_context(ctx, &violating_aliases);
103        autofix_candidates.sort_by_key(|candidate| candidate.span.start);
104        let candidates_align = autofix_candidates.len() == violation_count;
105        let legacy_candidates =
106            legacy_self_alias_candidates_for_context(ctx, self.alias_case_check, strategy);
107        if !legacy_candidates.is_empty()
108            && (violation_count == 0
109                || !candidates_align
110                || contains_assignment_alias_pattern(ctx.statement_sql()))
111        {
112            return vec![Issue::info(
113                issue_codes::LINT_AL_009,
114                "Column aliases should not alias to itself.",
115            )
116            .with_statement(ctx.statement_index)
117            .with_span(legacy_candidates[0].span)
118            .with_autofix_edits(
119                IssueAutofixApplicability::Safe,
120                legacy_candidates
121                    .into_iter()
122                    .flat_map(|candidate| candidate.edits)
123                    .collect(),
124            )];
125        }
126
127        (0..violation_count)
128            .map(|index| {
129                let mut issue = Issue::info(
130                    issue_codes::LINT_AL_009,
131                    "Column aliases should not alias to itself.",
132                )
133                .with_statement(ctx.statement_index);
134
135                if candidates_align {
136                    let candidate = &autofix_candidates[index];
137                    issue = issue.with_span(candidate.span).with_autofix_edits(
138                        IssueAutofixApplicability::Safe,
139                        candidate.edits.clone(),
140                    );
141                }
142
143                issue
144            })
145            .collect()
146    }
147}
148
149#[derive(Clone, Debug)]
150struct PositionedToken {
151    token: Token,
152    start: usize,
153    end: usize,
154}
155
156#[derive(Clone, Debug)]
157struct Al009AutofixCandidate {
158    span: Span,
159    edits: Vec<IssuePatchEdit>,
160}
161
162fn al009_autofix_candidates_for_context(
163    ctx: &LintContext,
164    aliases: &[Ident],
165) -> Vec<Al009AutofixCandidate> {
166    if aliases.is_empty() {
167        return Vec::new();
168    }
169
170    let tokens = statement_positioned_tokens(ctx);
171    if tokens.is_empty() {
172        return Vec::new();
173    }
174
175    let mut candidates = Vec::new();
176
177    for alias in aliases {
178        let Some((alias_start, alias_end)) = ident_span_offsets(ctx.sql, alias) else {
179            continue;
180        };
181        if alias_start < ctx.statement_range.start || alias_end > ctx.statement_range.end {
182            continue;
183        }
184
185        let Some(alias_token_index) = tokens
186            .iter()
187            .position(|token| token.start == alias_start && token.end == alias_end)
188        else {
189            continue;
190        };
191
192        let Some(removal_span) = alias_removal_span(&tokens, alias_token_index) else {
193            continue;
194        };
195
196        candidates.push(Al009AutofixCandidate {
197            span: Span::new(alias_start, alias_end),
198            edits: vec![IssuePatchEdit::new(removal_span, "")],
199        });
200    }
201
202    candidates
203}
204
205fn statement_positioned_tokens(ctx: &LintContext) -> Vec<PositionedToken> {
206    let from_document_tokens = ctx.with_document_tokens(|tokens| {
207        if tokens.is_empty() {
208            return None;
209        }
210
211        let mut positioned = Vec::new();
212        for token in tokens {
213            let (start, end) = token_with_span_offsets(ctx.sql, token)?;
214            if start < ctx.statement_range.start || end > ctx.statement_range.end {
215                continue;
216            }
217
218            positioned.push(PositionedToken {
219                token: token.token.clone(),
220                start,
221                end,
222            });
223        }
224
225        Some(positioned)
226    });
227
228    if let Some(tokens) = from_document_tokens {
229        return tokens;
230    }
231
232    let dialect = ctx.dialect().to_sqlparser_dialect();
233    let mut tokenizer = Tokenizer::new(dialect.as_ref(), ctx.statement_sql());
234    let Ok(tokens) = tokenizer.tokenize_with_location() else {
235        return Vec::new();
236    };
237
238    let mut positioned = Vec::new();
239    for token in &tokens {
240        let Some((start, end)) = token_with_span_offsets(ctx.statement_sql(), token) else {
241            continue;
242        };
243        positioned.push(PositionedToken {
244            token: token.token.clone(),
245            start: ctx.statement_range.start + start,
246            end: ctx.statement_range.start + end,
247        });
248    }
249    positioned
250}
251
252fn alias_removal_span(tokens: &[PositionedToken], alias_token_index: usize) -> Option<Span> {
253    let alias = &tokens[alias_token_index];
254    let previous_non_trivia = previous_non_trivia_index(tokens, alias_token_index)?;
255
256    if token_is_as_keyword(&tokens[previous_non_trivia].token) {
257        let expression_token = previous_non_trivia_index(tokens, previous_non_trivia)?;
258        let gap_start = expression_token + 1;
259        if gap_start > previous_non_trivia
260            || trivia_contains_comment(tokens, gap_start, previous_non_trivia)
261            || trivia_contains_comment(tokens, previous_non_trivia + 1, alias_token_index)
262        {
263            return None;
264        }
265        return Some(Span::new(tokens[gap_start].start, alias.end));
266    }
267
268    let gap_start = previous_non_trivia + 1;
269    if gap_start >= alias_token_index
270        || trivia_contains_comment(tokens, gap_start, alias_token_index)
271    {
272        return None;
273    }
274
275    Some(Span::new(tokens[gap_start].start, alias.end))
276}
277
278fn previous_non_trivia_index(tokens: &[PositionedToken], before: usize) -> Option<usize> {
279    if before == 0 {
280        return None;
281    }
282
283    let mut index = before - 1;
284    loop {
285        if !is_trivia(&tokens[index].token) {
286            return Some(index);
287        }
288        if index == 0 {
289            return None;
290        }
291        index -= 1;
292    }
293}
294
295fn trivia_contains_comment(tokens: &[PositionedToken], start: usize, end: usize) -> bool {
296    if start >= end {
297        return false;
298    }
299
300    tokens[start..end].iter().any(|token| {
301        matches!(
302            token.token,
303            Token::Whitespace(
304                Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_)
305            )
306        )
307    })
308}
309
310fn token_is_as_keyword(token: &Token) -> bool {
311    matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case("AS"))
312}
313
314fn is_trivia(token: &Token) -> bool {
315    matches!(
316        token,
317        Token::Whitespace(
318            Whitespace::Space
319                | Whitespace::Newline
320                | Whitespace::Tab
321                | Whitespace::SingleLineComment { .. }
322                | Whitespace::MultiLineComment(_)
323        )
324    )
325}
326
327fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
328    let start = line_col_to_offset(
329        sql,
330        token.span.start.line as usize,
331        token.span.start.column as usize,
332    )?;
333    let end = line_col_to_offset(
334        sql,
335        token.span.end.line as usize,
336        token.span.end.column as usize,
337    )?;
338    Some((start, end))
339}
340
341fn ident_span_offsets(sql: &str, ident: &Ident) -> Option<(usize, usize)> {
342    let start = line_col_to_offset(
343        sql,
344        ident.span.start.line as usize,
345        ident.span.start.column as usize,
346    )?;
347    let end = line_col_to_offset(
348        sql,
349        ident.span.end.line as usize,
350        ident.span.end.column as usize,
351    )?;
352    Some((start, end))
353}
354
355fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
356    if line == 0 || column == 0 {
357        return None;
358    }
359
360    let mut current_line = 1usize;
361    let mut current_col = 1usize;
362
363    for (offset, ch) in sql.char_indices() {
364        if current_line == line && current_col == column {
365            return Some(offset);
366        }
367
368        if ch == '\n' {
369            current_line += 1;
370            current_col = 1;
371        } else {
372            current_col += 1;
373        }
374    }
375
376    if current_line == line && current_col == column {
377        return Some(sql.len());
378    }
379
380    None
381}
382
383fn aliases_expression_to_itself(
384    expr: &Expr,
385    alias: &Ident,
386    alias_case_check: AliasCaseCheck,
387    dialect_strategy: Option<NormalizationStrategy>,
388) -> bool {
389    let Some(source_name) = expression_name(expr) else {
390        return false;
391    };
392
393    let alias_name = NameRef {
394        name: alias.value.as_str(),
395        quoted: alias.quote_style.is_some(),
396    };
397
398    names_match(source_name, alias_name, alias_case_check, dialect_strategy)
399}
400
401fn expression_name(expr: &Expr) -> Option<NameRef<'_>> {
402    match expr {
403        Expr::Identifier(identifier) => Some(NameRef {
404            name: identifier.value.as_str(),
405            quoted: identifier.quote_style.is_some(),
406        }),
407        Expr::CompoundIdentifier(parts) => parts.last().map(|part| NameRef {
408            name: part.value.as_str(),
409            quoted: part.quote_style.is_some(),
410        }),
411        Expr::Nested(inner) => expression_name(inner),
412        _ => None,
413    }
414}
415
416fn names_match(
417    left: NameRef<'_>,
418    right: NameRef<'_>,
419    alias_case_check: AliasCaseCheck,
420    dialect_strategy: Option<NormalizationStrategy>,
421) -> bool {
422    match alias_case_check {
423        AliasCaseCheck::CaseInsensitive => left.name.eq_ignore_ascii_case(right.name),
424        AliasCaseCheck::CaseSensitive => left.name == right.name,
425        AliasCaseCheck::Dialect => {
426            let strategy = dialect_strategy.unwrap_or(NormalizationStrategy::CaseInsensitive);
427
428            // When quoting differs between the column and alias, the user
429            // deliberately chose different quoting styles. This signals
430            // intent rather than a redundant self-alias, so never flag it.
431            if left.quoted != right.quoted {
432                return false;
433            }
434
435            if left.quoted {
436                // Both quoted — exact match required.
437                left.name == right.name
438            } else {
439                // Both unquoted — compare using the dialect's folding strategy.
440                match strategy {
441                    NormalizationStrategy::CaseSensitive => left.name == right.name,
442                    NormalizationStrategy::CaseInsensitive
443                    | NormalizationStrategy::Lowercase
444                    | NormalizationStrategy::Uppercase => {
445                        left.name.eq_ignore_ascii_case(right.name)
446                    }
447                }
448            }
449        }
450        AliasCaseCheck::QuotedCsNakedUpper | AliasCaseCheck::QuotedCsNakedLower => {
451            normalize_name_for_mode(left, alias_case_check)
452                == normalize_name_for_mode(right, alias_case_check)
453        }
454    }
455}
456
457fn normalize_name_for_mode(name_ref: NameRef<'_>, mode: AliasCaseCheck) -> String {
458    match mode {
459        AliasCaseCheck::QuotedCsNakedUpper => {
460            if name_ref.quoted {
461                name_ref.name.to_string()
462            } else {
463                name_ref.name.to_ascii_uppercase()
464            }
465        }
466        AliasCaseCheck::QuotedCsNakedLower => {
467            if name_ref.quoted {
468                name_ref.name.to_string()
469            } else {
470                name_ref.name.to_ascii_lowercase()
471            }
472        }
473        _ => name_ref.name.to_string(),
474    }
475}
476
477fn legacy_self_alias_candidates_for_context(
478    ctx: &LintContext,
479    alias_case_check: AliasCaseCheck,
480    dialect_strategy: Option<NormalizationStrategy>,
481) -> Vec<Al009AutofixCandidate> {
482    let sql = ctx.statement_sql();
483    let Ok(select_clause_regex) = Regex::new(r"(?is)\bselect\b(?P<clause>.*?)\bfrom\b") else {
484        return Vec::new();
485    };
486    let Some(captures) = select_clause_regex.captures(sql) else {
487        return Vec::new();
488    };
489    let Some(clause) = captures.name("clause") else {
490        return Vec::new();
491    };
492
493    let clause_start = clause.start();
494    let clause_sql = clause.as_str();
495    let mut line_offset = 0usize;
496    let mut candidates = Vec::new();
497
498    for line in clause_sql.split_inclusive('\n') {
499        let line_no_newline = line.strip_suffix('\n').unwrap_or(line);
500        let mut content_start = 0usize;
501        while content_start < line_no_newline.len()
502            && line_no_newline.as_bytes()[content_start].is_ascii_whitespace()
503        {
504            content_start += 1;
505        }
506
507        let mut content_end = line_no_newline.len();
508        while content_end > content_start
509            && line_no_newline.as_bytes()[content_end - 1].is_ascii_whitespace()
510        {
511            content_end -= 1;
512        }
513        if content_end > content_start && line_no_newline.as_bytes()[content_end - 1] == b',' {
514            content_end -= 1;
515        }
516        while content_end > content_start
517            && line_no_newline.as_bytes()[content_end - 1].is_ascii_whitespace()
518        {
519            content_end -= 1;
520        }
521        if content_end <= content_start {
522            line_offset += line.len();
523            continue;
524        }
525
526        let content = &line_no_newline[content_start..content_end];
527        let Some(replacement) = legacy_self_alias_replacement(
528            content,
529            ctx.dialect(),
530            alias_case_check,
531            dialect_strategy,
532        ) else {
533            line_offset += line.len();
534            continue;
535        };
536        if replacement == content {
537            line_offset += line.len();
538            continue;
539        }
540
541        let edit_start = clause_start + line_offset + content_start;
542        let edit_end = clause_start + line_offset + content_end;
543        let span = ctx.span_from_statement_offset(edit_start, edit_end);
544        candidates.push(Al009AutofixCandidate {
545            span,
546            edits: vec![IssuePatchEdit::new(span, replacement)],
547        });
548
549        line_offset += line.len();
550    }
551
552    candidates
553}
554
555fn legacy_self_alias_replacement(
556    target: &str,
557    dialect: crate::types::Dialect,
558    alias_case_check: AliasCaseCheck,
559    dialect_strategy: Option<NormalizationStrategy>,
560) -> Option<String> {
561    if dialect == crate::types::Dialect::Bigquery
562        && target.starts_with('`')
563        && target.ends_with('`')
564    {
565        let inner = &target[1..target.len().saturating_sub(1)];
566        if let Some(split_at) = inner.find("``") {
567            let left = &inner[..split_at];
568            let right = &inner[split_at + 2..];
569            if !left.is_empty() && left == right {
570                return Some(format!("`{left}`"));
571            }
572        }
573    }
574
575    if let Some(eq_pos) = target.find('=') {
576        let prev = eq_pos
577            .checked_sub(1)
578            .and_then(|idx| target.as_bytes().get(idx).copied());
579        let next = target.as_bytes().get(eq_pos + 1).copied();
580        if !matches!(prev, Some(b'!') | Some(b'<') | Some(b'>')) && !matches!(next, Some(b'=')) {
581            let alias_raw = target[..eq_pos].trim();
582            let expr_raw = target[eq_pos + 1..].trim();
583            if let (Some(expr_name), Some(alias_name)) = (
584                parse_identifier_name(expr_raw),
585                parse_identifier_name(alias_raw),
586            ) {
587                if names_match(expr_name, alias_name, alias_case_check, dialect_strategy) {
588                    return Some(expr_raw.to_string());
589                }
590            }
591        }
592    }
593
594    let upper = target.to_ascii_uppercase();
595    if let Some(as_pos) = upper.find(" AS ") {
596        let expr_raw = target[..as_pos].trim();
597        let alias_raw = target[as_pos + 4..].trim();
598        if let (Some(expr_name), Some(alias_name)) = (
599            parse_identifier_name(expr_raw),
600            parse_identifier_name(alias_raw),
601        ) {
602            if names_match(expr_name, alias_name, alias_case_check, dialect_strategy) {
603                return Some(expr_raw.to_string());
604            }
605        }
606    }
607
608    let mut parts = target.split_whitespace();
609    let first = parts.next()?;
610    let second = parts.next()?;
611    if parts.next().is_none() {
612        if let (Some(expr_name), Some(alias_name)) =
613            (parse_identifier_name(first), parse_identifier_name(second))
614        {
615            if names_match(expr_name, alias_name, alias_case_check, dialect_strategy) {
616                return Some(first.to_string());
617            }
618        }
619    }
620
621    None
622}
623
624fn parse_identifier_name(raw: &str) -> Option<NameRef<'_>> {
625    if raw.len() >= 2 {
626        let bytes = raw.as_bytes();
627        if (bytes[0] == b'"' && bytes[raw.len() - 1] == b'"')
628            || (bytes[0] == b'`' && bytes[raw.len() - 1] == b'`')
629            || (bytes[0] == b'[' && bytes[raw.len() - 1] == b']')
630        {
631            return Some(NameRef {
632                name: &raw[1..raw.len() - 1],
633                quoted: true,
634            });
635        }
636    }
637
638    let mut chars = raw.chars();
639    let first = chars.next()?;
640    if !(first.is_ascii_alphabetic() || first == '_') {
641        return None;
642    }
643    if !chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$')) {
644        return None;
645    }
646    Some(NameRef {
647        name: raw,
648        quoted: false,
649    })
650}
651
652fn contains_assignment_alias_pattern(sql: &str) -> bool {
653    let Ok(pattern) =
654        Regex::new(r"(?im)^\s*[A-Za-z_][A-Za-z0-9_$]*\s*=\s*[A-Za-z_][A-Za-z0-9_$]*\s*,?\s*$")
655    else {
656        return false;
657    };
658    pattern.is_match(sql)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::linter::rule::with_active_dialect;
665    use crate::parser::{parse_sql, parse_sql_with_dialect};
666    use crate::types::{Dialect, IssueAutofixApplicability};
667
668    fn run(sql: &str) -> Vec<Issue> {
669        let statements = parse_sql(sql).expect("parse");
670        let rule = AliasingSelfAliasColumn::default();
671        statements
672            .iter()
673            .enumerate()
674            .flat_map(|(index, statement)| {
675                rule.check(
676                    statement,
677                    &LintContext {
678                        sql,
679                        statement_range: 0..sql.len(),
680                        statement_index: index,
681                    },
682                )
683            })
684            .collect()
685    }
686
687    fn run_in_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
688        let statements = parse_sql_with_dialect(sql, dialect).expect("parse");
689        let rule = AliasingSelfAliasColumn::default();
690        let mut issues = Vec::new();
691        with_active_dialect(dialect, || {
692            for (index, statement) in statements.iter().enumerate() {
693                issues.extend(rule.check(
694                    statement,
695                    &LintContext {
696                        sql,
697                        statement_range: 0..sql.len(),
698                        statement_index: index,
699                    },
700                ));
701            }
702        });
703        issues
704    }
705
706    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
707        let autofix = issue.autofix.as_ref()?;
708        let mut out = sql.to_string();
709        let mut edits = autofix.edits.clone();
710        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
711        for edit in edits.into_iter().rev() {
712            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
713        }
714        Some(out)
715    }
716
717    #[test]
718    fn flags_plain_self_alias() {
719        let issues = run("SELECT a AS a FROM t");
720        assert_eq!(issues.len(), 1);
721        assert_eq!(issues[0].code, issue_codes::LINT_AL_009);
722    }
723
724    #[test]
725    fn flags_qualified_self_alias() {
726        let issues = run("SELECT t.a AS a FROM t");
727        assert_eq!(issues.len(), 1);
728    }
729
730    #[test]
731    fn flags_case_insensitive_self_alias() {
732        let issues = run("SELECT a AS A FROM t");
733        assert_eq!(issues.len(), 1);
734    }
735
736    #[test]
737    fn does_not_flag_distinct_alias_name() {
738        let issues = run("SELECT a AS b FROM t");
739        assert!(issues.is_empty());
740    }
741
742    #[test]
743    fn does_not_flag_non_identifier_expression() {
744        let issues = run("SELECT a + 1 AS a FROM t");
745        assert!(issues.is_empty());
746    }
747
748    #[test]
749    fn default_dialect_mode_does_not_flag_quoted_case_mismatch() {
750        let issues = run("SELECT \"A\" AS a FROM t");
751        assert!(issues.is_empty());
752    }
753
754    #[test]
755    fn default_dialect_mode_flags_exact_quoted_match() {
756        let issues = run("SELECT \"A\" AS \"A\" FROM t");
757        assert_eq!(issues.len(), 1);
758    }
759
760    #[test]
761    fn alias_case_check_case_sensitive_respects_case() {
762        let sql = "SELECT a AS A FROM t";
763        let statements = parse_sql(sql).expect("parse");
764        let rule = AliasingSelfAliasColumn::from_config(&LintConfig {
765            enabled: true,
766            disabled_rules: vec![],
767            rule_configs: std::collections::BTreeMap::from([(
768                "aliasing.self_alias.column".to_string(),
769                serde_json::json!({"alias_case_check": "case_sensitive"}),
770            )]),
771        });
772        let issues = rule.check(
773            &statements[0],
774            &LintContext {
775                sql,
776                statement_range: 0..sql.len(),
777                statement_index: 0,
778            },
779        );
780        assert!(issues.is_empty());
781    }
782
783    #[test]
784    fn alias_case_check_quoted_cs_naked_upper_flags_upper_fold_match() {
785        let sql = "SELECT \"FOO\" AS foo FROM t";
786        let statements = parse_sql(sql).expect("parse");
787        let rule = AliasingSelfAliasColumn::from_config(&LintConfig {
788            enabled: true,
789            disabled_rules: vec![],
790            rule_configs: std::collections::BTreeMap::from([(
791                "aliasing.self_alias.column".to_string(),
792                serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
793            )]),
794        });
795        let issues = rule.check(
796            &statements[0],
797            &LintContext {
798                sql,
799                statement_range: 0..sql.len(),
800                statement_index: 0,
801            },
802        );
803        assert_eq!(issues.len(), 1);
804    }
805
806    #[test]
807    fn alias_case_check_quoted_cs_naked_upper_allows_nonmatching_quoted_case() {
808        let sql = "SELECT \"foo\" AS foo FROM t";
809        let statements = parse_sql(sql).expect("parse");
810        let rule = AliasingSelfAliasColumn::from_config(&LintConfig {
811            enabled: true,
812            disabled_rules: vec![],
813            rule_configs: std::collections::BTreeMap::from([(
814                "aliasing.self_alias.column".to_string(),
815                serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
816            )]),
817        });
818        let issues = rule.check(
819            &statements[0],
820            &LintContext {
821                sql,
822                statement_range: 0..sql.len(),
823                statement_index: 0,
824            },
825        );
826        assert!(issues.is_empty());
827    }
828
829    #[test]
830    fn alias_case_check_quoted_cs_naked_lower_flags_lower_fold_match() {
831        let sql = "SELECT \"foo\" AS FOO FROM t";
832        let statements = parse_sql(sql).expect("parse");
833        let rule = AliasingSelfAliasColumn::from_config(&LintConfig {
834            enabled: true,
835            disabled_rules: vec![],
836            rule_configs: std::collections::BTreeMap::from([(
837                "aliasing.self_alias.column".to_string(),
838                serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
839            )]),
840        });
841        let issues = rule.check(
842            &statements[0],
843            &LintContext {
844                sql,
845                statement_range: 0..sql.len(),
846                statement_index: 0,
847            },
848        );
849        assert_eq!(issues.len(), 1);
850    }
851
852    #[test]
853    fn alias_case_check_quoted_cs_naked_lower_allows_nonmatching_quoted_case() {
854        let sql = "SELECT \"FOO\" AS FOO FROM t";
855        let statements = parse_sql(sql).expect("parse");
856        let rule = AliasingSelfAliasColumn::from_config(&LintConfig {
857            enabled: true,
858            disabled_rules: vec![],
859            rule_configs: std::collections::BTreeMap::from([(
860                "aliasing.self_alias.column".to_string(),
861                serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
862            )]),
863        });
864        let issues = rule.check(
865            &statements[0],
866            &LintContext {
867                sql,
868                statement_range: 0..sql.len(),
869                statement_index: 0,
870            },
871        );
872        assert!(issues.is_empty());
873    }
874
875    #[test]
876    fn self_alias_with_as_emits_safe_autofix_patch() {
877        let sql = "SELECT a AS a FROM t";
878        let issues = run(sql);
879        assert_eq!(issues.len(), 1);
880
881        let autofix = issues[0]
882            .autofix
883            .as_ref()
884            .expect("expected AL009 core autofix metadata");
885        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
886        assert_eq!(autofix.edits.len(), 1);
887        let edit = &autofix.edits[0];
888        assert_eq!(&sql[edit.span.start..edit.span.end], " AS a");
889        assert_eq!(edit.replacement, "");
890    }
891
892    #[test]
893    fn self_alias_without_as_emits_safe_autofix_patch() {
894        let sql = "SELECT a a FROM t";
895        let issues = run(sql);
896        assert_eq!(issues.len(), 1);
897
898        let autofix = issues[0]
899            .autofix
900            .as_ref()
901            .expect("expected AL009 core autofix metadata");
902        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
903        assert_eq!(autofix.edits.len(), 1);
904        let edit = &autofix.edits[0];
905        assert_eq!(&sql[edit.span.start..edit.span.end], " a");
906        assert_eq!(edit.replacement, "");
907    }
908
909    // --- SQLFluff parity: dialect-aware matching ---
910
911    #[test]
912    fn clickhouse_case_sensitive_no_false_positives() {
913        // SQLFluff: test_pass_different_case_clickhouse
914        // ClickHouse is CaseSensitive — different case means different identifier.
915        let sql = "select col_b as Col_B, COL_C as col_c, Col_D as COL_D from foo";
916        let issues = run_in_dialect(sql, Dialect::Clickhouse);
917        assert!(issues.is_empty());
918    }
919
920    #[test]
921    fn clickhouse_quoted_case_sensitive_no_false_positives() {
922        // SQLFluff: test_pass_different_case_clickhouse (quoted portion)
923        let sql = r#"select "col_b" as "Col_B", "COL_C" as "col_c", "Col_D" as "COL_D" from foo"#;
924        let issues = run_in_dialect(sql, Dialect::Clickhouse);
925        assert!(issues.is_empty());
926    }
927
928    #[test]
929    fn different_quotes_not_flagged() {
930        // SQLFluff: test_pass_different_quotes (ansi dialect)
931        // When one side is quoted and the other is not, the identifier
932        // semantics differ (quoted preserves case, unquoted folds to upper
933        // in ANSI). These should not be flagged as self-aliases.
934        let sql = r#"select "col_b" as col_b, COL_C as "COL_C", "Col_D" as Col_D from foo"#;
935        let issues = run_in_dialect(sql, Dialect::Ansi);
936        assert!(issues.is_empty());
937    }
938
939    #[test]
940    fn bigquery_backtick_self_alias_detected() {
941        // SQLFluff: test_fail_bigquery_quoted_column_no_space_with_as
942        let sql = "SELECT `col`as`col` FROM clients as c";
943        let issues = run_in_dialect(sql, Dialect::Bigquery);
944        assert_eq!(issues.len(), 1);
945    }
946
947    #[test]
948    fn tsql_self_alias_assignments_use_legacy_fallback_fix() {
949        let sql = "select\n    this_alias_is_fine = col_a,\n    col_b = col_b,\n    COL_C AS COL_C,\n    Col_D = Col_D,\n    col_e col_e,\n    COL_F COL_F,\n    Col_G Col_G\nfrom foo";
950        let statements = parse_sql("SELECT 1").expect("synthetic parse");
951        let rule = AliasingSelfAliasColumn::default();
952        let issues = with_active_dialect(Dialect::Mssql, || {
953            rule.check(
954                &statements[0],
955                &LintContext {
956                    sql,
957                    statement_range: 0..sql.len(),
958                    statement_index: 0,
959                },
960            )
961        });
962        assert_eq!(issues.len(), 1);
963        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
964        assert_eq!(
965            fixed,
966            "select\n    this_alias_is_fine = col_a,\n    col_b,\n    COL_C,\n    Col_D,\n    col_e,\n    COL_F,\n    Col_G\nfrom foo"
967        );
968    }
969
970    #[test]
971    fn bigquery_adjacent_backtick_self_alias_uses_legacy_fallback_fix() {
972        let sql = "SELECT `col``col`\nFROM clients as c";
973        let statements = parse_sql("SELECT 1").expect("synthetic parse");
974        let rule = AliasingSelfAliasColumn::default();
975        let issues = with_active_dialect(Dialect::Bigquery, || {
976            rule.check(
977                &statements[0],
978                &LintContext {
979                    sql,
980                    statement_range: 0..sql.len(),
981                    statement_index: 0,
982                },
983            )
984        });
985        assert_eq!(issues.len(), 1);
986        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
987        assert_eq!(fixed, "SELECT `col`\nFROM clients as c");
988    }
989
990    #[test]
991    fn tsql_parsed_statement_still_gets_self_alias_autofix() {
992        let sql = "select\n    this_alias_is_fine = col_a,\n    col_b = col_b,\n    COL_C AS COL_C,\n    Col_D = Col_D,\n    col_e col_e,\n    COL_F COL_F,\n    Col_G Col_G\nfrom foo";
993        let issues = run_in_dialect(sql, Dialect::Mssql);
994        assert_eq!(issues.len(), 1);
995        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
996        assert_eq!(
997            fixed,
998            "select\n    this_alias_is_fine = col_a,\n    col_b,\n    COL_C,\n    Col_D,\n    col_e,\n    COL_F,\n    Col_G\nfrom foo"
999        );
1000    }
1001}