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 loop {
145 let Some(dot_idx) = next_non_trivia_index(tokens, table_end_idx + 1) else {
146 break;
147 };
148 if !matches!(tokens[dot_idx].token, Token::Period) {
149 break;
150 }
151 let Some(next_part_idx) = next_non_trivia_index(tokens, dot_idx + 1) else {
152 break;
153 };
154 if !is_identifier_token(&tokens[next_part_idx].token) {
155 break;
156 }
157 table_end_idx = next_part_idx;
158 }
159
160 let alias_idx = next_non_trivia_index(tokens, table_end_idx + 1)?;
161 let Token::Word(alias_word) = &tokens[alias_idx].token else {
162 continue;
163 };
164 if alias_word.keyword != Keyword::NoKeyword {
165 continue;
166 }
167
168 let table_start = tokens[table_start_idx].start;
169 let table_end = tokens[table_end_idx].end;
170 if table_start >= table_end || table_end > sql.len() {
171 continue;
172 }
173
174 return Some(FallbackAliasCandidate {
175 table_name: sql[table_start..table_end].to_string(),
176 alias_name: alias_word.value.clone(),
177 alias_start: tokens[alias_idx].start,
178 alias_end: tokens[alias_idx].end,
179 });
180 }
181
182 None
183}
184
185fn token_is_keyword(token: &Token, keyword: &str) -> bool {
186 matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(keyword))
187}
188
189fn is_identifier_token(token: &Token) -> bool {
190 matches!(token, Token::Word(_) | Token::Placeholder(_))
191}
192
193fn next_non_trivia_index(tokens: &[LocatedToken], mut index: usize) -> Option<usize> {
194 while index < tokens.len() {
195 if !is_trivia_token(&tokens[index].token) {
196 return Some(index);
197 }
198 index += 1;
199 }
200 None
201}
202
203fn contains_join_keyword(tokens: &[LocatedToken]) -> bool {
204 tokens.iter().any(|token| {
205 matches!(
206 &token.token,
207 Token::Word(word)
208 if word.value.eq_ignore_ascii_case("JOIN")
209 || word.value.eq_ignore_ascii_case("LEFT")
210 || word.value.eq_ignore_ascii_case("RIGHT")
211 || word.value.eq_ignore_ascii_case("FULL")
212 || word.value.eq_ignore_ascii_case("INNER")
213 || word.value.eq_ignore_ascii_case("CROSS")
214 )
215 })
216}
217
218#[derive(Clone)]
220struct UnnecessaryAlias {
221 table_name: String,
223 alias_ident: Ident,
225}
226
227fn collect_unnecessary_aliases(select: &Select) -> Vec<UnnecessaryAlias> {
229 let mut candidates = Vec::new();
230 for table in &select.from {
231 collect_alias_candidates_from_table_with_joins(table, &mut candidates);
232 }
233
234 if candidates.is_empty() {
235 return Vec::new();
236 }
237
238 let mut table_occurrence_counts: HashMap<String, usize> = HashMap::new();
240 for (canonical, _has_alias, _table_name, _alias) in &candidates {
241 *table_occurrence_counts
242 .entry(canonical.clone())
243 .or_insert(0) += 1;
244 }
245
246 let is_multi_source = candidates.len() > 1;
247
248 candidates
249 .into_iter()
250 .filter_map(|(canonical, has_alias, table_name, alias_ident)| {
251 if !has_alias {
252 return None;
253 }
254 if is_multi_source
255 && table_occurrence_counts
256 .get(&canonical)
257 .copied()
258 .unwrap_or(0)
259 > 1
260 {
261 return None;
262 }
263 Some(UnnecessaryAlias {
264 table_name,
265 alias_ident: alias_ident?,
266 })
267 })
268 .collect()
269}
270
271type AliasCandidate = (
272 String, bool, String, Option<Ident>, );
277
278fn collect_alias_candidates_from_table_with_joins(
279 table: &TableWithJoins,
280 candidates: &mut Vec<AliasCandidate>,
281) {
282 collect_alias_candidates_from_table_factor(&table.relation, candidates);
283 for join in &table.joins {
284 collect_alias_candidates_from_table_factor(&join.relation, candidates);
285 }
286}
287
288fn collect_alias_candidates_from_table_factor(
289 table_factor: &TableFactor,
290 candidates: &mut Vec<AliasCandidate>,
291) {
292 match table_factor {
293 TableFactor::Table { name, alias, .. } => {
294 let canonical = name.to_string().to_ascii_uppercase();
295 let table_name = name.to_string();
296 let alias_ident = alias.as_ref().map(|a| a.name.clone());
297 candidates.push((canonical, alias.is_some(), table_name, alias_ident));
298 }
299 TableFactor::NestedJoin {
300 table_with_joins, ..
301 } => {
302 collect_alias_candidates_from_table_with_joins(table_with_joins, candidates);
303 }
304 TableFactor::Pivot { table, .. }
305 | TableFactor::Unpivot { table, .. }
306 | TableFactor::MatchRecognize { table, .. } => {
307 collect_alias_candidates_from_table_factor(table, candidates);
308 }
309 _ => {}
310 }
311}
312
313fn build_autofix_edits(
319 alias_info: &UnnecessaryAlias,
320 all_aliases: &[UnnecessaryAlias],
321 ctx: &LintContext,
322 tokens: Option<&[LocatedToken]>,
323) -> Vec<IssuePatchEdit> {
324 let Some(tokens) = tokens else {
325 return Vec::new();
326 };
327
328 let alias_name = &alias_info.alias_ident.value;
329 let table_name = &alias_info.table_name;
330
331 let alias_abs_start = line_col_to_offset(
333 ctx.sql,
334 alias_info.alias_ident.span.start.line as usize,
335 alias_info.alias_ident.span.start.column as usize,
336 );
337 let alias_abs_end = line_col_to_offset(
338 ctx.sql,
339 alias_info.alias_ident.span.end.line as usize,
340 alias_info.alias_ident.span.end.column as usize,
341 );
342 let (Some(alias_abs_start), Some(alias_abs_end)) = (alias_abs_start, alias_abs_end) else {
343 return Vec::new();
344 };
345
346 if alias_abs_start < ctx.statement_range.start || alias_abs_end > ctx.statement_range.end {
347 return Vec::new();
348 }
349
350 let rel_alias_start = alias_abs_start - ctx.statement_range.start;
351 let rel_alias_end = alias_abs_end - ctx.statement_range.start;
352
353 let mut edits = Vec::new();
354
355 if let Some(delete_span) = alias_declaration_delete_span(tokens, rel_alias_start, rel_alias_end)
357 {
358 edits.push(IssuePatchEdit::new(
359 ctx.span_from_statement_offset(delete_span.start, delete_span.end),
360 "",
361 ));
362 }
363
364 let alias_refs = find_qualified_alias_references(tokens, alias_name, all_aliases);
366 for (ref_start, ref_end) in alias_refs {
367 edits.push(IssuePatchEdit::new(
368 ctx.span_from_statement_offset(ref_start, ref_end),
369 table_name.clone(),
370 ));
371 }
372
373 edits
374}
375
376fn alias_declaration_delete_span(
382 tokens: &[LocatedToken],
383 alias_start: usize,
384 alias_end: usize,
385) -> Option<Span> {
386 let mut delete_start = alias_start;
392
393 let mut found_as = false;
395 for token in tokens.iter().rev() {
396 if token.end > alias_start {
397 continue;
398 }
399 if is_trivia_token(&token.token) {
400 if token.start < delete_start {
402 delete_start = token.start;
403 }
404 continue;
405 }
406 if is_as_token(&token.token) {
407 found_as = true;
409 delete_start = token.start;
410 }
411 break;
412 }
413
414 if found_as {
416 for token in tokens.iter().rev() {
417 if token.end > delete_start {
418 continue;
419 }
420 if is_trivia_token(&token.token) {
421 if token.start < delete_start {
422 delete_start = token.start;
423 }
424 continue;
425 }
426 break;
427 }
428 }
429
430 if delete_start < alias_end {
431 Some(Span::new(delete_start, alias_end))
432 } else {
433 None
434 }
435}
436
437fn find_qualified_alias_references(
443 tokens: &[LocatedToken],
444 alias_name: &str,
445 all_aliases: &[UnnecessaryAlias],
446) -> Vec<(usize, usize)> {
447 let mut refs = Vec::new();
448
449 for (i, token) in tokens.iter().enumerate() {
450 let Token::Word(word) = &token.token else {
452 continue;
453 };
454 if !word.value.eq_ignore_ascii_case(alias_name) {
455 continue;
456 }
457 let next_non_trivia = tokens[i + 1..].iter().find(|t| !is_trivia_token(&t.token));
459 if !next_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
460 continue;
461 }
462 let prev_non_trivia = tokens[..i]
464 .iter()
465 .rev()
466 .find(|t| !is_trivia_token(&t.token));
467 if prev_non_trivia.is_some_and(|t| matches!(t.token, Token::Period)) {
468 continue;
469 }
470 if all_aliases.iter().any(|a| {
472 let a_start = line_col_to_absolute_offset(
473 a.alias_ident.span.start.line as usize,
474 a.alias_ident.span.start.column as usize,
475 );
476 a_start.is_some_and(|s| s == token.start)
477 }) {
478 continue;
479 }
480 refs.push((token.start, token.end));
481 }
482
483 refs
484}
485
486fn line_col_to_absolute_offset(_line: usize, _column: usize) -> Option<usize> {
489 None
492}
493
494#[derive(Clone)]
495struct LocatedToken {
496 token: Token,
497 start: usize,
498 end: usize,
499}
500
501fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
502 let dialect = dialect.to_sqlparser_dialect();
503 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
504 let tokens = tokenizer.tokenize_with_location().ok()?;
505
506 let mut out = Vec::with_capacity(tokens.len());
507 for token in tokens {
508 let (start, end) = token_with_span_offsets(sql, &token)?;
509 out.push(LocatedToken {
510 token: token.token,
511 start,
512 end,
513 });
514 }
515 Some(out)
516}
517
518fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
519 let statement_start = ctx.statement_range.start;
520 ctx.with_document_tokens(|tokens| {
521 if tokens.is_empty() {
522 return None;
523 }
524
525 Some(
526 tokens
527 .iter()
528 .filter_map(|token| {
529 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
530 if start < ctx.statement_range.start || end > ctx.statement_range.end {
531 return None;
532 }
533 Some(LocatedToken {
534 token: token.token.clone(),
535 start: start - statement_start,
536 end: end - statement_start,
537 })
538 })
539 .collect::<Vec<_>>(),
540 )
541 })
542}
543
544fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
545 let start = line_col_to_offset(
546 sql,
547 token.span.start.line as usize,
548 token.span.start.column as usize,
549 )?;
550 let end = line_col_to_offset(
551 sql,
552 token.span.end.line as usize,
553 token.span.end.column as usize,
554 )?;
555 Some((start, end))
556}
557
558fn is_trivia_token(token: &Token) -> bool {
559 matches!(
560 token,
561 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
562 | Token::Whitespace(Whitespace::SingleLineComment { .. })
563 | Token::Whitespace(Whitespace::MultiLineComment(_))
564 )
565}
566
567fn is_as_token(token: &Token) -> bool {
568 match token {
569 Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
570 _ => false,
571 }
572}
573
574fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
575 if line == 0 || column == 0 {
576 return None;
577 }
578
579 let mut current_line = 1usize;
580 let mut current_col = 1usize;
581
582 for (offset, ch) in sql.char_indices() {
583 if current_line == line && current_col == column {
584 return Some(offset);
585 }
586
587 if ch == '\n' {
588 current_line += 1;
589 current_col = 1;
590 } else {
591 current_col += 1;
592 }
593 }
594
595 if current_line == line && current_col == column {
596 return Some(sql.len());
597 }
598
599 None
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::linter::rule::with_active_dialect;
606 use crate::parser::parse_sql;
607 use crate::types::Dialect;
608
609 fn run(sql: &str) -> Vec<Issue> {
610 let statements = parse_sql(sql).expect("parse");
611 let rule = AliasingForbidSingleTable::default();
612 statements
613 .iter()
614 .enumerate()
615 .flat_map(|(index, statement)| {
616 rule.check(
617 statement,
618 &LintContext {
619 sql,
620 statement_range: 0..sql.len(),
621 statement_index: index,
622 },
623 )
624 })
625 .collect()
626 }
627
628 fn run_force_enabled_statementless_mssql(sql: &str) -> Vec<Issue> {
629 let synthetic = parse_sql("SELECT 1").expect("parse");
630 let config = LintConfig {
631 enabled: true,
632 disabled_rules: vec![],
633 rule_configs: std::collections::BTreeMap::from([(
634 "aliasing.forbid".to_string(),
635 serde_json::json!({"force_enable": true}),
636 )]),
637 };
638 let rule = AliasingForbidSingleTable::from_config(&config);
639 with_active_dialect(Dialect::Mssql, || {
640 rule.check(
641 &synthetic[0],
642 &LintContext {
643 sql,
644 statement_range: 0..sql.len(),
645 statement_index: 0,
646 },
647 )
648 })
649 }
650
651 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
652 let autofix = issue.autofix.as_ref()?;
653 let mut out = sql.to_string();
654 let mut edits = autofix.edits.clone();
655 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
656 for edit in edits.into_iter().rev() {
657 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
658 }
659 Some(out)
660 }
661
662 fn run_force_enabled(sql: &str) -> Vec<Issue> {
663 let statements = parse_sql(sql).expect("parse");
664 let config = LintConfig {
665 enabled: true,
666 disabled_rules: vec![],
667 rule_configs: std::collections::BTreeMap::from([(
668 "aliasing.forbid".to_string(),
669 serde_json::json!({"force_enable": true}),
670 )]),
671 };
672 let rule = AliasingForbidSingleTable::from_config(&config);
673 statements
674 .iter()
675 .enumerate()
676 .flat_map(|(index, statement)| {
677 rule.check(
678 statement,
679 &LintContext {
680 sql,
681 statement_range: 0..sql.len(),
682 statement_index: index,
683 },
684 )
685 })
686 .collect()
687 }
688
689 #[test]
690 fn disabled_by_default() {
691 let issues = run("SELECT * FROM users u");
692 assert!(issues.is_empty());
693 }
694
695 #[test]
696 fn flags_single_table_alias_when_force_enabled() {
697 let issues = run_force_enabled("SELECT * FROM users u");
698 assert_eq!(issues.len(), 1);
699 assert_eq!(issues[0].code, issue_codes::LINT_AL_007);
700 }
701
702 #[test]
703 fn does_not_flag_single_table_without_alias() {
704 let issues = run_force_enabled("SELECT * FROM users");
705 assert!(issues.is_empty());
706 }
707
708 #[test]
709 fn flags_multi_source_query_when_force_enabled() {
710 let issues = run_force_enabled("SELECT * FROM users u JOIN orders o ON u.id = o.user_id");
711 assert_eq!(issues.len(), 2);
712 }
713
714 #[test]
715 fn allows_self_join_aliases() {
716 let issues = run_force_enabled("SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id");
717 assert!(issues.is_empty());
718 }
719
720 #[test]
721 fn flags_non_self_join_alias_in_self_join_scope() {
722 let issues = run_force_enabled(
723 "SELECT * FROM users u1 JOIN users u2 ON u1.id = u2.id JOIN orders o ON o.user_id = u1.id",
724 );
725 assert_eq!(issues.len(), 1);
726 }
727
728 #[test]
729 fn does_not_flag_derived_table_alias() {
730 let issues = run_force_enabled("SELECT * FROM (SELECT 1 AS id) sub");
731 assert!(issues.is_empty());
732 }
733
734 #[test]
735 fn flags_nested_single_table_alias() {
736 let issues = run_force_enabled("SELECT * FROM (SELECT * FROM users u) sub");
737 assert_eq!(issues.len(), 1);
738 }
739
740 #[test]
741 fn force_enable_false_disables_rule() {
742 let config = LintConfig {
743 enabled: true,
744 disabled_rules: vec![],
745 rule_configs: std::collections::BTreeMap::from([(
746 "aliasing.forbid".to_string(),
747 serde_json::json!({"force_enable": false}),
748 )]),
749 };
750 let rule = AliasingForbidSingleTable::from_config(&config);
751 let sql = "SELECT * FROM users u";
752 let statements = parse_sql(sql).expect("parse");
753 let issues = rule.check(
754 &statements[0],
755 &LintContext {
756 sql,
757 statement_range: 0..sql.len(),
758 statement_index: 0,
759 },
760 );
761 assert!(issues.is_empty());
762 }
763
764 #[test]
765 fn single_table_alias_autofix_removes_alias_and_replaces_refs() {
766 let sql = "SELECT u.id FROM users AS u";
767 let issues = run_force_enabled(sql);
768 assert_eq!(issues.len(), 1);
769 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
770 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
771 assert!(
773 autofix.edits.len() >= 2,
774 "expected at least 2 edits, got: {:?}",
775 autofix.edits
776 );
777 }
778
779 #[test]
780 fn multi_table_alias_autofix() {
781 let sql = "SELECT u.id, o.total FROM users AS u JOIN orders AS o ON u.id = o.user_id";
782 let issues = run_force_enabled(sql);
783 assert_eq!(issues.len(), 2);
784 for issue in &issues {
785 assert!(issue.autofix.is_some(), "expected autofix on AL07 issue");
786 }
787 }
788
789 #[test]
790 fn statementless_tsql_create_table_as_alias_fallback_detects_and_fixes() {
791 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";
792 let issues = run_force_enabled_statementless_mssql(sql);
793 assert_eq!(issues.len(), 1);
794 let fixed = apply_issue_autofix(sql, &issues[0]).expect("autofix");
795 assert_eq!(
796 fixed,
797 "DECLARE @VariableE date = GETDATE()\n\nCREATE TABLE #TempTable\nAS\n(\n Select ColumnD\n from SchemaA.TableB\n where ColumnD >= @VariableE\n)\n"
798 );
799 }
800}