1use 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 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 if left.quoted != right.quoted {
432 return false;
433 }
434
435 if left.quoted {
436 left.name == right.name
438 } else {
439 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 #[test]
912 fn clickhouse_case_sensitive_no_false_positives() {
913 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 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 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 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}