1use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
9use sqlparser::ast::{Ident, Select, Statement, TableFactor, TableWithJoins};
10use sqlparser::keywords::Keyword;
11use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
12use std::collections::HashMap;
13
14use super::semantic_helpers::visit_selects_in_statement;
15
16#[derive(Default)]
17pub struct AliasingForbidSingleTable {
18 force_enable: bool,
19}
20
21impl AliasingForbidSingleTable {
22 pub fn from_config(config: &LintConfig) -> Self {
23 Self {
24 force_enable: config
25 .rule_option_bool(issue_codes::LINT_AL_007, "force_enable")
26 .unwrap_or(false),
27 }
28 }
29}
30
31impl LintRule for AliasingForbidSingleTable {
32 fn code(&self) -> &'static str {
33 issue_codes::LINT_AL_007
34 }
35
36 fn name(&self) -> &'static str {
37 "Forbid unnecessary alias"
38 }
39
40 fn description(&self) -> &'static str {
41 "Avoid table aliases in from clauses and join conditions."
42 }
43
44 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
45 if !self.force_enable {
46 return Vec::new();
47 }
48
49 let tokens =
50 tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
51
52 let mut issues = Vec::new();
53
54 visit_selects_in_statement(statement, &mut |select| {
55 let aliases = collect_unnecessary_aliases(select);
56 for alias_info in &aliases {
57 let edits = build_autofix_edits(alias_info, &aliases, ctx, tokens.as_deref());
58 let mut issue =
59 Issue::info(issue_codes::LINT_AL_007, "Avoid unnecessary table aliases.")
60 .with_statement(ctx.statement_index);
61 if !edits.is_empty() {
62 issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
63 }
64 issues.push(issue);
65 }
66 });
67
68 if issues.is_empty() {
69 if let Some(issue) = fallback_single_from_alias_issue(ctx, tokens.as_deref()) {
70 issues.push(issue);
71 }
72 }
73
74 issues
75 }
76}
77
78#[derive(Clone)]
79struct FallbackAliasCandidate {
80 table_name: String,
81 alias_name: String,
82 alias_start: usize,
83 alias_end: usize,
84}
85
86fn fallback_single_from_alias_issue(
87 ctx: &LintContext,
88 tokens: Option<&[LocatedToken]>,
89) -> Option<Issue> {
90 if ctx.dialect() != Dialect::Mssql {
91 return None;
92 }
93 let tokens = tokens?;
94 if tokens.is_empty() || contains_join_keyword(tokens) {
95 return None;
96 }
97
98 let candidate = fallback_single_from_alias_candidate(tokens, ctx.statement_sql())?;
99 let mut edits = Vec::new();
100
101 if let Some(delete_span) =
102 alias_declaration_delete_span(tokens, candidate.alias_start, candidate.alias_end)
103 {
104 edits.push(IssuePatchEdit::new(
105 ctx.span_from_statement_offset(delete_span.start, delete_span.end),
106 "",
107 ));
108 }
109
110 for (ref_start, ref_end) in find_qualified_alias_references(tokens, &candidate.alias_name, &[])
111 {
112 edits.push(IssuePatchEdit::new(
113 ctx.span_from_statement_offset(ref_start, ref_end),
114 candidate.table_name.clone(),
115 ));
116 }
117
118 let mut issue = Issue::info(issue_codes::LINT_AL_007, "Avoid unnecessary table aliases.")
119 .with_statement(ctx.statement_index)
120 .with_span(ctx.span_from_statement_offset(candidate.alias_start, candidate.alias_end));
121
122 if !edits.is_empty() {
123 issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
124 }
125
126 Some(issue)
127}
128
129fn fallback_single_from_alias_candidate(
130 tokens: &[LocatedToken],
131 sql: &str,
132) -> Option<FallbackAliasCandidate> {
133 for (index, token) in tokens.iter().enumerate() {
134 if !token_is_keyword(&token.token, "FROM") {
135 continue;
136 }
137
138 let table_start_idx = next_non_trivia_index(tokens, index + 1)?;
139 if !is_identifier_token(&tokens[table_start_idx].token) {
140 continue;
141 }
142
143 let mut table_end_idx = table_start_idx;
144 while let Some(dot_idx) = next_non_trivia_index(tokens, table_end_idx + 1) {
145 if !matches!(tokens[dot_idx].token, Token::Period) {
146 break;
147 }
148 let Some(next_part_idx) = next_non_trivia_index(tokens, dot_idx + 1) else {
149 break;
150 };
151 if !is_identifier_token(&tokens[next_part_idx].token) {
152 break;
153 }
154 table_end_idx = next_part_idx;
155 }
156
157 let alias_idx = next_non_trivia_index(tokens, table_end_idx + 1)?;
158 let Token::Word(alias_word) = &tokens[alias_idx].token else {
159 continue;
160 };
161 if alias_word.keyword != Keyword::NoKeyword {
162 continue;
163 }
164
165 let table_start = tokens[table_start_idx].start;
166 let table_end = tokens[table_end_idx].end;
167 if table_start >= table_end || table_end > sql.len() {
168 continue;
169 }
170
171 return Some(FallbackAliasCandidate {
172 table_name: sql[table_start..table_end].to_string(),
173 alias_name: alias_word.value.clone(),
174 alias_start: tokens[alias_idx].start,
175 alias_end: tokens[alias_idx].end,
176 });
177 }
178
179 None
180}
181
182fn token_is_keyword(token: &Token, keyword: &str) -> bool {
183 matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(keyword))
184}
185
186fn is_identifier_token(token: &Token) -> bool {
187 matches!(token, Token::Word(_) | Token::Placeholder(_))
188}
189
190fn next_non_trivia_index(tokens: &[LocatedToken], mut index: usize) -> Option<usize> {
191 while index < tokens.len() {
192 if !is_trivia_token(&tokens[index].token) {
193 return Some(index);
194 }
195 index += 1;
196 }
197 None
198}
199
200fn contains_join_keyword(tokens: &[LocatedToken]) -> bool {
201 tokens.iter().any(|token| {
202 matches!(
203 &token.token,
204 Token::Word(word)
205 if word.value.eq_ignore_ascii_case("JOIN")
206 || word.value.eq_ignore_ascii_case("LEFT")
207 || word.value.eq_ignore_ascii_case("RIGHT")
208 || word.value.eq_ignore_ascii_case("FULL")
209 || word.value.eq_ignore_ascii_case("INNER")
210 || word.value.eq_ignore_ascii_case("CROSS")
211 )
212 })
213}
214
215#[derive(Clone)]
217struct UnnecessaryAlias {
218 table_name: String,
220 alias_ident: Ident,
222}
223
224fn collect_unnecessary_aliases(select: &Select) -> Vec<UnnecessaryAlias> {
226 let mut candidates = Vec::new();
227 for table in &select.from {
228 collect_alias_candidates_from_table_with_joins(table, &mut candidates);
229 }
230
231 if candidates.is_empty() {
232 return Vec::new();
233 }
234
235 let mut table_occurrence_counts: HashMap<String, usize> = HashMap::new();
237 for (canonical, _has_alias, _table_name, _alias) in &candidates {
238 *table_occurrence_counts
239 .entry(canonical.clone())
240 .or_insert(0) += 1;
241 }
242
243 let is_multi_source = candidates.len() > 1;
244
245 candidates
246 .into_iter()
247 .filter_map(|(canonical, has_alias, table_name, alias_ident)| {
248 if !has_alias {
249 return None;
250 }
251 if is_multi_source
252 && table_occurrence_counts
253 .get(&canonical)
254 .copied()
255 .unwrap_or(0)
256 > 1
257 {
258 return None;
259 }
260 Some(UnnecessaryAlias {
261 table_name,
262 alias_ident: alias_ident?,
263 })
264 })
265 .collect()
266}
267
268type AliasCandidate = (
269 String, bool, String, Option<Ident>, );
274
275fn collect_alias_candidates_from_table_with_joins(
276 table: &TableWithJoins,
277 candidates: &mut Vec<AliasCandidate>,
278) {
279 collect_alias_candidates_from_table_factor(&table.relation, candidates);
280 for join in &table.joins {
281 collect_alias_candidates_from_table_factor(&join.relation, candidates);
282 }
283}
284
285fn collect_alias_candidates_from_table_factor(
286 table_factor: &TableFactor,
287 candidates: &mut Vec<AliasCandidate>,
288) {
289 match table_factor {
290 TableFactor::Table { name, alias, .. } => {
291 let canonical = name.to_string().to_ascii_uppercase();
292 let table_name = name.to_string();
293 let alias_ident = alias.as_ref().map(|a| a.name.clone());
294 candidates.push((canonical, alias.is_some(), table_name, alias_ident));
295 }
296 TableFactor::NestedJoin {
297 table_with_joins, ..
298 } => {
299 collect_alias_candidates_from_table_with_joins(table_with_joins, candidates);
300 }
301 TableFactor::Pivot { table, .. }
302 | TableFactor::Unpivot { table, .. }
303 | TableFactor::MatchRecognize { table, .. } => {
304 collect_alias_candidates_from_table_factor(table, candidates);
305 }
306 _ => {}
307 }
308}
309
310fn build_autofix_edits(
316 alias_info: &UnnecessaryAlias,
317 all_aliases: &[UnnecessaryAlias],
318 ctx: &LintContext,
319 tokens: Option<&[LocatedToken]>,
320) -> Vec<IssuePatchEdit> {
321 let Some(tokens) = tokens else {
322 return Vec::new();
323 };
324
325 let alias_name = &alias_info.alias_ident.value;
326 let table_name = &alias_info.table_name;
327
328 let alias_abs_start = line_col_to_offset(
330 ctx.sql,
331 alias_info.alias_ident.span.start.line as usize,
332 alias_info.alias_ident.span.start.column as usize,
333 );
334 let alias_abs_end = line_col_to_offset(
335 ctx.sql,
336 alias_info.alias_ident.span.end.line as usize,
337 alias_info.alias_ident.span.end.column as usize,
338 );
339 let (Some(alias_abs_start), Some(alias_abs_end)) = (alias_abs_start, alias_abs_end) else {
340 return Vec::new();
341 };
342
343 if alias_abs_start < ctx.statement_range.start || alias_abs_end > ctx.statement_range.end {
344 return Vec::new();
345 }
346
347 let rel_alias_start = alias_abs_start - ctx.statement_range.start;
348 let rel_alias_end = alias_abs_end - ctx.statement_range.start;
349
350 let mut edits = Vec::new();
351
352 if let Some(delete_span) = alias_declaration_delete_span(tokens, rel_alias_start, rel_alias_end)
354 {
355 edits.push(IssuePatchEdit::new(
356 ctx.span_from_statement_offset(delete_span.start, delete_span.end),
357 "",
358 ));
359 }
360
361 let alias_refs = find_qualified_alias_references(tokens, alias_name, all_aliases);
363 for (ref_start, ref_end) in alias_refs {
364 edits.push(IssuePatchEdit::new(
365 ctx.span_from_statement_offset(ref_start, ref_end),
366 table_name.clone(),
367 ));
368 }
369
370 edits
371}
372
373fn alias_declaration_delete_span(
379 tokens: &[LocatedToken],
380 alias_start: usize,
381 alias_end: usize,
382) -> Option<Span> {
383 let mut delete_start = alias_start;
389
390 let mut found_as = false;
392 for token in tokens.iter().rev() {
393 if token.end > alias_start {
394 continue;
395 }
396 if is_trivia_token(&token.token) {
397 if token.start < delete_start {
399 delete_start = token.start;
400 }
401 continue;
402 }
403 if is_as_token(&token.token) {
404 found_as = true;
406 delete_start = token.start;
407 }
408 break;
409 }
410
411 if found_as {
413 for token in tokens.iter().rev() {
414 if token.end > delete_start {
415 continue;
416 }
417 if is_trivia_token(&token.token) {
418 if token.start < delete_start {
419 delete_start = token.start;
420 }
421 continue;
422 }
423 break;
424 }
425 }
426
427 if delete_start < alias_end {
428 Some(Span::new(delete_start, alias_end))
429 } else {
430 None
431 }
432}
433
434fn find_qualified_alias_references(
440 tokens: &[LocatedToken],
441 alias_name: &str,
442 all_aliases: &[UnnecessaryAlias],
443) -> Vec<(usize, usize)> {
444 let mut refs = Vec::new();
445
446 for (i, token) in tokens.iter().enumerate() {
447 let Token::Word(word) = &token.token else {
449 continue;
450 };
451 if !word.value.eq_ignore_ascii_case(alias_name) {
452 continue;
453 }
454 let next_non_trivia = tokens[i + 1..].iter().find(|t| !is_trivia_token(&t.token));
456 if !next_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
457 continue;
458 }
459 let prev_non_trivia = tokens[..i]
461 .iter()
462 .rev()
463 .find(|t| !is_trivia_token(&t.token));
464 if prev_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
465 continue;
466 }
467 if all_aliases.iter().any(|a| {
469 let a_start = line_col_to_absolute_offset(
470 a.alias_ident.span.start.line as usize,
471 a.alias_ident.span.start.column as usize,
472 );
473 a_start.is_some_and(|s| s == token.start)
474 }) {
475 continue;
476 }
477 refs.push((token.start, token.end));
478 }
479
480 refs
481}
482
483fn line_col_to_absolute_offset(_line: usize, _column: usize) -> Option<usize> {
486 None
489}
490
491#[derive(Clone)]
492struct LocatedToken {
493 token: Token,
494 start: usize,
495 end: usize,
496}
497
498fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
499 let dialect = dialect.to_sqlparser_dialect();
500 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
501 let tokens = tokenizer.tokenize_with_location().ok()?;
502
503 let mut out = Vec::with_capacity(tokens.len());
504 for token in tokens {
505 let (start, end) = token_with_span_offsets(sql, &token)?;
506 out.push(LocatedToken {
507 token: token.token,
508 start,
509 end,
510 });
511 }
512 Some(out)
513}
514
515fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
516 let statement_start = ctx.statement_range.start;
517 ctx.with_document_tokens(|tokens| {
518 if tokens.is_empty() {
519 return None;
520 }
521
522 Some(
523 tokens
524 .iter()
525 .filter_map(|token| {
526 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
527 if start < ctx.statement_range.start || end > ctx.statement_range.end {
528 return None;
529 }
530 Some(LocatedToken {
531 token: token.token.clone(),
532 start: start - statement_start,
533 end: end - statement_start,
534 })
535 })
536 .collect::<Vec<_>>(),
537 )
538 })
539}
540
541fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
542 let start = line_col_to_offset(
543 sql,
544 token.span.start.line as usize,
545 token.span.start.column as usize,
546 )?;
547 let end = line_col_to_offset(
548 sql,
549 token.span.end.line as usize,
550 token.span.end.column as usize,
551 )?;
552 Some((start, end))
553}
554
555fn is_trivia_token(token: &Token) -> bool {
556 matches!(
557 token,
558 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
559 | Token::Whitespace(Whitespace::SingleLineComment { .. })
560 | Token::Whitespace(Whitespace::MultiLineComment(_))
561 )
562}
563
564fn is_as_token(token: &Token) -> bool {
565 match token {
566 Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
567 _ => false,
568 }
569}
570
571fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
572 if line == 0 || column == 0 {
573 return None;
574 }
575
576 let mut current_line = 1usize;
577 let mut current_col = 1usize;
578
579 for (offset, ch) in sql.char_indices() {
580 if current_line == line && current_col == column {
581 return Some(offset);
582 }
583
584 if ch == '\n' {
585 current_line += 1;
586 current_col = 1;
587 } else {
588 current_col += 1;
589 }
590 }
591
592 if current_line == line && current_col == column {
593 return Some(sql.len());
594 }
595
596 None
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use crate::linter::rule::with_active_dialect;
603 use crate::parser::parse_sql;
604 use crate::types::Dialect;
605
606 fn run(sql: &str) -> Vec<Issue> {
607 let statements = parse_sql(sql).expect("parse");
608 let rule = AliasingForbidSingleTable::default();
609 statements
610 .iter()
611 .enumerate()
612 .flat_map(|(index, statement)| {
613 rule.check(
614 statement,
615 &LintContext {
616 sql,
617 statement_range: 0..sql.len(),
618 statement_index: index,
619 },
620 )
621 })
622 .collect()
623 }
624
625 fn run_force_enabled_statementless_mssql(sql: &str) -> Vec<Issue> {
626 let synthetic = parse_sql("SELECT 1").expect("parse");
627 let config = LintConfig {
628 enabled: true,
629 disabled_rules: vec![],
630 rule_configs: std::collections::BTreeMap::from([(
631 "aliasing.forbid".to_string(),
632 serde_json::json!({"force_enable": true}),
633 )]),
634 };
635 let rule = AliasingForbidSingleTable::from_config(&config);
636 with_active_dialect(Dialect::Mssql, || {
637 rule.check(
638 &synthetic[0],
639 &LintContext {
640 sql,
641 statement_range: 0..sql.len(),
642 statement_index: 0,
643 },
644 )
645 })
646 }
647
648 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
649 let autofix = issue.autofix.as_ref()?;
650 let mut out = sql.to_string();
651 let mut edits = autofix.edits.clone();
652 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
653 for edit in edits.into_iter().rev() {
654 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
655 }
656 Some(out)
657 }
658
659 fn run_force_enabled(sql: &str) -> Vec<Issue> {
660 let statements = parse_sql(sql).expect("parse");
661 let config = LintConfig {
662 enabled: true,
663 disabled_rules: vec![],
664 rule_configs: std::collections::BTreeMap::from([(
665 "aliasing.forbid".to_string(),
666 serde_json::json!({"force_enable": true}),
667 )]),
668 };
669 let rule = AliasingForbidSingleTable::from_config(&config);
670 statements
671 .iter()
672 .enumerate()
673 .flat_map(|(index, statement)| {
674 rule.check(
675 statement,
676 &LintContext {
677 sql,
678 statement_range: 0..sql.len(),
679 statement_index: index,
680 },
681 )
682 })
683 .collect()
684 }
685
686 #[test]
687 fn disabled_by_default() {
688 let issues = run("SELECT * FROM users u");
689 assert!(issues.is_empty());
690 }
691
692 #[test]
693 fn flags_single_table_alias_when_force_enabled() {
694 let issues = run_force_enabled("SELECT * FROM users u");
695 assert_eq!(issues.len(), 1);
696 assert_eq!(issues[0].code, issue_codes::LINT_AL_007);
697 }
698
699 #[test]
700 fn does_not_flag_single_table_without_alias() {
701 let issues = run_force_enabled("SELECT * FROM users");
702 assert!(issues.is_empty());
703 }
704
705 #[test]
706 fn flags_multi_source_query_when_force_enabled() {
707 let issues = run_force_enabled("SELECT * FROM users u JOIN orders o ON u.id = o.user_id");
708 assert_eq!(issues.len(), 2);
709 }
710
711 #[test]
712 fn allows_self_join_aliases() {
713 let issues = run_force_enabled("SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id");
714 assert!(issues.is_empty());
715 }
716
717 #[test]
718 fn flags_non_self_join_alias_in_self_join_scope() {
719 let issues = run_force_enabled(
720 "SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id JOIN orders o ON o.user_id = u1.id",
721 );
722 assert_eq!(issues.len(), 1);
723 }
724
725 #[test]
726 fn does_not_flag_derived_table_alias() {
727 let issues = run_force_enabled("SELECT * FROM (SELECT 1 AS id) sub");
728 assert!(issues.is_empty());
729 }
730
731 #[test]
732 fn flags_nested_single_table_alias() {
733 let issues = run_force_enabled("SELECT * FROM (SELECT * FROM users u) sub");
734 assert_eq!(issues.len(), 1);
735 }
736
737 #[test]
738 fn force_enable_false_disables_rule() {
739 let config = LintConfig {
740 enabled: true,
741 disabled_rules: vec![],
742 rule_configs: std::collections::BTreeMap::from([(
743 "aliasing.forbid".to_string(),
744 serde_json::json!({"force_enable": false}),
745 )]),
746 };
747 let rule = AliasingForbidSingleTable::from_config(&config);
748 let sql = "SELECT * FROM users u";
749 let statements = parse_sql(sql).expect("parse");
750 let issues = rule.check(
751 &statements[0],
752 &LintContext {
753 sql,
754 statement_range: 0..sql.len(),
755 statement_index: 0,
756 },
757 );
758 assert!(issues.is_empty());
759 }
760
761 #[test]
762 fn single_table_alias_autofix_removes_alias_and_replaces_refs() {
763 let sql = "SELECT u.id FROM users AS u";
764 let issues = run_force_enabled(sql);
765 assert_eq!(issues.len(), 1);
766 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
767 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
768 assert!(
770 autofix.edits.len() >= 2,
771 "expected at least 2 edits, got: {:?}",
772 autofix.edits
773 );
774 }
775
776 #[test]
777 fn multi_table_alias_autofix() {
778 let sql = "SELECT u.id, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id";
779 let issues = run_force_enabled(sql);
780 assert_eq!(issues.len(), 2);
781 for issue in &issues {
782 assert!(issue.autofix.is_some(), "expected autofix on AL07 issue");
783 }
784 }
785
786 #[test]
787 fn statementless_tsql_create_table_as_alias_fallback_detects_and_fixes() {
788 let sql = "DECLARE @VariableE date = GETDATE()\n\nCREATE TABLE #TempTable\nAS\n(\n Select ColumnD\n from SchemaA.TableB AliasC\n where ColumnD >= @VariableE\n)\n";
789 let issues = run_force_enabled_statementless_mssql(sql);
790 assert_eq!(issues.len(), 1);
791 let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
792 assert_eq!(
793 fixed,
794 "DECLARE @VariableE date = GETDATE()\n\nCREATE TABLE #TempTable\nAS\n(\n Select ColumnD\n from SchemaA.TableB\n where ColumnD >= @VariableE\n)\n"
795 );
796 }
797}