1use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
9use sqlparser::ast::{JoinOperator, Select, Statement};
10use sqlparser::keywords::Keyword;
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 FullyQualifyJoinTypes {
17 Inner,
18 Outer,
19 Both,
20}
21
22impl FullyQualifyJoinTypes {
23 fn from_config(config: &LintConfig) -> Self {
24 match config
25 .rule_option_str(issue_codes::LINT_AM_005, "fully_qualify_join_types")
26 .unwrap_or("inner")
27 .to_ascii_lowercase()
28 .as_str()
29 {
30 "outer" => Self::Outer,
31 "both" => Self::Both,
32 _ => Self::Inner,
33 }
34 }
35}
36
37pub struct AmbiguousJoinStyle {
38 qualify_mode: FullyQualifyJoinTypes,
39}
40
41impl AmbiguousJoinStyle {
42 pub fn from_config(config: &LintConfig) -> Self {
43 Self {
44 qualify_mode: FullyQualifyJoinTypes::from_config(config),
45 }
46 }
47}
48
49impl Default for AmbiguousJoinStyle {
50 fn default() -> Self {
51 Self {
52 qualify_mode: FullyQualifyJoinTypes::Inner,
53 }
54 }
55}
56
57impl LintRule for AmbiguousJoinStyle {
58 fn code(&self) -> &'static str {
59 issue_codes::LINT_AM_005
60 }
61
62 fn name(&self) -> &'static str {
63 "Ambiguous join style"
64 }
65
66 fn description(&self) -> &'static str {
67 "Join clauses should be fully qualified."
68 }
69
70 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
71 let mut plain_join_count = 0usize;
72
73 visit_selects_in_statement(statement, &mut |select| {
74 for table in &select.from {
75 for join in &table.joins {
76 if matches!(join.join_operator, JoinOperator::Join(_)) {
77 plain_join_count += 1;
78 }
79 }
80 }
81 });
82
83 let outer_unqualified_count = count_unqualified_outer_joins(statement, ctx);
84 let violation_count = match self.qualify_mode {
85 FullyQualifyJoinTypes::Inner => plain_join_count,
86 FullyQualifyJoinTypes::Outer => outer_unqualified_count,
87 FullyQualifyJoinTypes::Both => plain_join_count + outer_unqualified_count,
88 };
89 let mut autofix_candidates = am005_autofix_candidates_for_context(ctx, self.qualify_mode);
90 autofix_candidates.sort_by_key(|candidate| candidate.span.start);
91 let candidates_align = autofix_candidates.len() == violation_count;
92
93 (0..violation_count)
94 .map(|index| {
95 let mut issue = Issue::warning(
96 issue_codes::LINT_AM_005,
97 "Join clauses should be fully qualified.",
98 )
99 .with_statement(ctx.statement_index);
100 if candidates_align {
101 let candidate = &autofix_candidates[index];
102 issue = issue.with_span(candidate.span).with_autofix_edits(
103 IssueAutofixApplicability::Safe,
104 candidate.edits.clone(),
105 );
106 }
107 issue
108 })
109 .collect()
110 }
111}
112
113#[derive(Clone, Debug)]
114struct PositionedToken {
115 token: Token,
116 start: usize,
117 end: usize,
118}
119
120#[derive(Clone, Debug)]
121struct Am005AutofixCandidate {
122 span: Span,
123 edits: Vec<IssuePatchEdit>,
124}
125
126fn am005_autofix_candidates_for_context(
127 ctx: &LintContext,
128 qualify_mode: FullyQualifyJoinTypes,
129) -> Vec<Am005AutofixCandidate> {
130 let from_document_tokens = ctx.with_document_tokens(|tokens| {
131 if tokens.is_empty() {
132 return None;
133 }
134
135 let mut positioned = Vec::new();
136 for token in tokens {
137 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
138 if start < ctx.statement_range.start || end > ctx.statement_range.end {
139 continue;
140 }
141 positioned.push(PositionedToken {
142 token: token.token.clone(),
143 start,
144 end,
145 });
146 }
147
148 Some(positioned)
149 });
150
151 if let Some(positioned) = from_document_tokens {
152 return am005_autofix_candidates_from_positioned_tokens(&positioned, qualify_mode);
153 }
154
155 let dialect = ctx.dialect().to_sqlparser_dialect();
156 let mut tokenizer = Tokenizer::new(dialect.as_ref(), ctx.statement_sql());
157 let Ok(tokens) = tokenizer.tokenize_with_location() else {
158 return Vec::new();
159 };
160
161 let mut positioned = Vec::new();
162 for token in &tokens {
163 let Some((start, end)) = token_with_span_offsets(ctx.statement_sql(), token) else {
164 continue;
165 };
166 positioned.push(PositionedToken {
167 token: token.token.clone(),
168 start: ctx.statement_range.start + start,
169 end: ctx.statement_range.start + end,
170 });
171 }
172
173 am005_autofix_candidates_from_positioned_tokens(&positioned, qualify_mode)
174}
175
176fn am005_autofix_candidates_from_positioned_tokens(
177 tokens: &[PositionedToken],
178 qualify_mode: FullyQualifyJoinTypes,
179) -> Vec<Am005AutofixCandidate> {
180 let significant_indexes: Vec<usize> = tokens
181 .iter()
182 .enumerate()
183 .filter_map(|(index, token)| (!is_trivia(&token.token)).then_some(index))
184 .collect();
185
186 let mut candidates = Vec::new();
187
188 for (position, token_index) in significant_indexes.iter().copied().enumerate() {
189 if !token_word_equals(&tokens[token_index].token, "JOIN") {
190 continue;
191 }
192
193 let previous = position
194 .checked_sub(1)
195 .and_then(|index| significant_indexes.get(index))
196 .copied();
197 let previous_previous = position
198 .checked_sub(2)
199 .and_then(|index| significant_indexes.get(index))
200 .copied();
201
202 let has_explicit_outer = previous.is_some_and(|index| {
203 token_word_equals(&tokens[index].token, "OUTER")
204 && previous_previous
205 .is_some_and(|inner| is_outer_join_side_keyword(&tokens[inner].token))
206 });
207 let requires_outer_keyword = !has_explicit_outer
208 && previous.is_some_and(|index| is_outer_join_side_keyword(&tokens[index].token));
209 let is_plain = is_plain_join_sequence(tokens, previous, previous_previous);
210
211 let join_token = &tokens[token_index];
212 let source_is_lower =
213 token_word_value(&join_token.token).is_some_and(|v| v == v.to_ascii_lowercase());
214
215 let needs_inner = match qualify_mode {
216 FullyQualifyJoinTypes::Inner | FullyQualifyJoinTypes::Both => is_plain,
217 FullyQualifyJoinTypes::Outer => false,
218 };
219 let needs_outer = match qualify_mode {
220 FullyQualifyJoinTypes::Outer | FullyQualifyJoinTypes::Both => requires_outer_keyword,
221 FullyQualifyJoinTypes::Inner => false,
222 };
223
224 if needs_inner {
225 let replacement = if source_is_lower {
226 "inner join"
227 } else {
228 "INNER JOIN"
229 };
230 let span = Span::new(join_token.start, join_token.end);
231 candidates.push(Am005AutofixCandidate {
232 span,
233 edits: vec![IssuePatchEdit::new(span, replacement)],
234 });
235 } else if needs_outer {
236 let outer_kw = if source_is_lower { "outer" } else { "OUTER" };
240 let join_kw = if source_is_lower { "join" } else { "JOIN" };
241 let replacement = format!("{outer_kw} {join_kw}");
242 let span = Span::new(join_token.start, join_token.end);
243 candidates.push(Am005AutofixCandidate {
244 span,
245 edits: vec![IssuePatchEdit::new(span, &replacement)],
246 });
247 } else {
248 continue;
249 }
250 }
251
252 candidates
253}
254
255fn is_plain_join_sequence(
256 tokens: &[PositionedToken],
257 previous: Option<usize>,
258 previous_previous: Option<usize>,
259) -> bool {
260 let Some(previous) = previous else {
261 return false;
262 };
263
264 if token_word_equals(&tokens[previous].token, "OUTER")
265 && previous_previous.is_some_and(|index| is_outer_join_side_keyword(&tokens[index].token))
266 {
267 return false;
268 }
269
270 if is_outer_join_side_keyword(&tokens[previous].token)
271 || token_word_equals(&tokens[previous].token, "INNER")
272 || token_word_equals(&tokens[previous].token, "CROSS")
273 || token_word_equals(&tokens[previous].token, "SEMI")
274 || token_word_equals(&tokens[previous].token, "ANTI")
275 || token_word_equals(&tokens[previous].token, "ASOF")
276 || token_word_equals(&tokens[previous].token, "OUTER")
277 || token_word_equals(&tokens[previous].token, "APPLY")
278 || token_word_equals(&tokens[previous].token, "STRAIGHT")
279 || token_word_equals(&tokens[previous].token, "STRAIGHT_JOIN")
280 {
281 return false;
282 }
283
284 true
285}
286
287fn token_word_equals(token: &Token, expected_upper: &str) -> bool {
288 matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(expected_upper))
289}
290
291fn token_word_value(token: &Token) -> Option<&str> {
292 match token {
293 Token::Word(word) => Some(&word.value),
294 _ => None,
295 }
296}
297
298fn is_outer_join_side_keyword(token: &Token) -> bool {
299 token_word_equals(token, "LEFT")
300 || token_word_equals(token, "RIGHT")
301 || token_word_equals(token, "FULL")
302}
303
304fn count_unqualified_outer_joins(statement: &Statement, ctx: &LintContext) -> usize {
305 count_unqualified_left_right_outer_joins(statement)
306 + count_unqualified_full_outer_joins(statement, ctx)
307}
308
309fn count_unqualified_left_right_outer_joins(statement: &Statement) -> usize {
310 let mut count = 0usize;
311
312 visit_selects_in_statement(statement, &mut |select| {
313 count += select_unqualified_left_right_outer_join_count(select);
314 });
315
316 count
317}
318
319fn select_unqualified_left_right_outer_join_count(select: &Select) -> usize {
320 select
321 .from
322 .iter()
323 .map(|table| {
324 table
325 .joins
326 .iter()
327 .filter(|join| {
328 matches!(
329 join.join_operator,
330 JoinOperator::Left(_) | JoinOperator::Right(_)
331 )
332 })
333 .count()
334 })
335 .sum()
336}
337
338fn count_unqualified_full_outer_joins(statement: &Statement, ctx: &LintContext) -> usize {
339 let full_outer_join_count = count_full_outer_joins(statement);
340 if full_outer_join_count == 0 {
341 return 0;
342 }
343
344 let explicit_full_outer_count = count_explicit_full_outer_joins_for_context(ctx);
345 full_outer_join_count.saturating_sub(explicit_full_outer_count)
346}
347
348fn count_full_outer_joins(statement: &Statement) -> usize {
349 let mut count = 0usize;
350 visit_selects_in_statement(statement, &mut |select| {
351 for table in &select.from {
352 for join in &table.joins {
353 if matches!(join.join_operator, JoinOperator::FullOuter(_)) {
354 count += 1;
355 }
356 }
357 }
358 });
359 count
360}
361
362fn count_explicit_full_outer_joins(sql: &str, dialect: Dialect) -> usize {
363 let dialect = dialect.to_sqlparser_dialect();
364 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
365 let Ok(tokens) = tokenizer.tokenize() else {
366 return 0;
367 };
368
369 count_explicit_full_outer_joins_from_tokens(&tokens)
370}
371
372fn count_explicit_full_outer_joins_for_context(ctx: &LintContext) -> usize {
373 let from_document_tokens = ctx.with_document_tokens(|tokens| {
374 if tokens.is_empty() {
375 return None;
376 }
377
378 Some(
379 tokens
380 .iter()
381 .filter_map(|token| {
382 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
383 if start < ctx.statement_range.start || end > ctx.statement_range.end {
384 return None;
385 }
386 Some(token.token.clone())
387 })
388 .collect::<Vec<_>>(),
389 )
390 });
391
392 if let Some(tokens) = from_document_tokens {
393 return count_explicit_full_outer_joins_from_tokens(&tokens);
394 }
395
396 count_explicit_full_outer_joins(ctx.statement_sql(), ctx.dialect())
397}
398
399fn count_explicit_full_outer_joins_from_tokens(tokens: &[Token]) -> usize {
400 let significant: Vec<&Token> = tokens.iter().filter(|token| !is_trivia(token)).collect();
401
402 let mut count = 0usize;
403 let mut idx = 0usize;
404 while idx < significant.len() {
405 let Token::Word(word) = significant[idx] else {
406 idx += 1;
407 continue;
408 };
409
410 if word.keyword != Keyword::FULL {
411 idx += 1;
412 continue;
413 }
414
415 let Some(next) = significant.get(idx + 1) else {
416 break;
417 };
418
419 match next {
420 Token::Word(next_word) if next_word.keyword == Keyword::OUTER => {
421 if matches!(
422 significant.get(idx + 2),
423 Some(Token::Word(join_word)) if join_word.keyword == Keyword::JOIN
424 ) {
425 count += 1;
426 idx += 3;
427 } else {
428 idx += 2;
429 }
430 }
431 _ => idx += 1,
432 }
433 }
434
435 count
436}
437
438fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
439 let start = line_col_to_offset(
440 sql,
441 token.span.start.line as usize,
442 token.span.start.column as usize,
443 )?;
444 let end = line_col_to_offset(
445 sql,
446 token.span.end.line as usize,
447 token.span.end.column as usize,
448 )?;
449 Some((start, end))
450}
451
452fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
453 if line == 0 || column == 0 {
454 return None;
455 }
456
457 let mut current_line = 1usize;
458 let mut current_col = 1usize;
459
460 for (offset, ch) in sql.char_indices() {
461 if current_line == line && current_col == column {
462 return Some(offset);
463 }
464
465 if ch == '\n' {
466 current_line += 1;
467 current_col = 1;
468 } else {
469 current_col += 1;
470 }
471 }
472
473 if current_line == line && current_col == column {
474 return Some(sql.len());
475 }
476
477 None
478}
479
480fn is_trivia(token: &Token) -> bool {
481 matches!(
482 token,
483 Token::Whitespace(
484 Whitespace::Space
485 | Whitespace::Newline
486 | Whitespace::Tab
487 | Whitespace::SingleLineComment { .. }
488 | Whitespace::MultiLineComment(_)
489 )
490 )
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::parser::parse_sql;
497 use crate::types::IssueAutofixApplicability;
498
499 fn run(sql: &str) -> Vec<Issue> {
500 let statements = parse_sql(sql).expect("parse");
501 let rule = AmbiguousJoinStyle::default();
502 statements
503 .iter()
504 .enumerate()
505 .flat_map(|(index, statement)| {
506 rule.check(
507 statement,
508 &LintContext {
509 sql,
510 statement_range: 0..sql.len(),
511 statement_index: index,
512 },
513 )
514 })
515 .collect()
516 }
517
518 #[test]
521 fn flags_plain_join() {
522 let issues = run("SELECT foo.a, bar.b FROM foo JOIN bar");
523 assert_eq!(issues.len(), 1);
524 assert_eq!(issues[0].code, issue_codes::LINT_AM_005);
525 }
526
527 #[test]
528 fn flags_lowercase_plain_join() {
529 let issues = run("SELECT foo.a, bar.b FROM foo join bar");
530 assert_eq!(issues.len(), 1);
531 }
532
533 #[test]
534 fn allows_inner_join() {
535 let issues = run("SELECT foo.a, bar.b FROM foo INNER JOIN bar");
536 assert!(issues.is_empty());
537 }
538
539 #[test]
540 fn allows_left_join() {
541 let issues = run("SELECT foo.a, bar.b FROM foo LEFT JOIN bar");
542 assert!(issues.is_empty());
543 }
544
545 #[test]
546 fn allows_right_join() {
547 let issues = run("SELECT foo.a, bar.b FROM foo RIGHT JOIN bar");
548 assert!(issues.is_empty());
549 }
550
551 #[test]
552 fn allows_full_join() {
553 let issues = run("SELECT foo.a, bar.b FROM foo FULL JOIN bar");
554 assert!(issues.is_empty());
555 }
556
557 #[test]
558 fn allows_left_outer_join() {
559 let issues = run("SELECT foo.a, bar.b FROM foo LEFT OUTER JOIN bar");
560 assert!(issues.is_empty());
561 }
562
563 #[test]
564 fn allows_right_outer_join() {
565 let issues = run("SELECT foo.a, bar.b FROM foo RIGHT OUTER JOIN bar");
566 assert!(issues.is_empty());
567 }
568
569 #[test]
570 fn allows_full_outer_join() {
571 let issues = run("SELECT foo.a, bar.b FROM foo FULL OUTER JOIN bar");
572 assert!(issues.is_empty());
573 }
574
575 #[test]
576 fn allows_cross_join() {
577 let issues = run("SELECT foo.a, bar.b FROM foo CROSS JOIN bar");
578 assert!(issues.is_empty());
579 }
580
581 #[test]
582 fn flags_each_plain_join_in_chain() {
583 let issues = run("SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id");
584 assert_eq!(issues.len(), 2);
585 assert!(issues
586 .iter()
587 .all(|issue| issue.code == issue_codes::LINT_AM_005));
588 }
589
590 #[test]
591 fn outer_mode_flags_left_join_without_outer_keyword() {
592 let config = LintConfig {
593 enabled: true,
594 disabled_rules: vec![],
595 rule_configs: std::collections::BTreeMap::from([(
596 "ambiguous.join".to_string(),
597 serde_json::json!({"fully_qualify_join_types": "outer"}),
598 )]),
599 };
600 let rule = AmbiguousJoinStyle::from_config(&config);
601 let sql = "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON foo.id = bar.id";
602 let statements = parse_sql(sql).expect("parse");
603 let issues = rule.check(
604 &statements[0],
605 &LintContext {
606 sql,
607 statement_range: 0..sql.len(),
608 statement_index: 0,
609 },
610 );
611 assert_eq!(issues.len(), 1);
612 }
613
614 #[test]
615 fn outer_mode_allows_left_outer_join() {
616 let config = LintConfig {
617 enabled: true,
618 disabled_rules: vec![],
619 rule_configs: std::collections::BTreeMap::from([(
620 "LINT_AM_005".to_string(),
621 serde_json::json!({"fully_qualify_join_types": "outer"}),
622 )]),
623 };
624 let rule = AmbiguousJoinStyle::from_config(&config);
625 let sql = "SELECT foo.a, bar.b FROM foo LEFT OUTER JOIN bar ON foo.id = bar.id";
626 let statements = parse_sql(sql).expect("parse");
627 let issues = rule.check(
628 &statements[0],
629 &LintContext {
630 sql,
631 statement_range: 0..sql.len(),
632 statement_index: 0,
633 },
634 );
635 assert!(issues.is_empty());
636 }
637
638 #[test]
639 fn outer_mode_flags_right_join_without_outer_keyword() {
640 let config = LintConfig {
641 enabled: true,
642 disabled_rules: vec![],
643 rule_configs: std::collections::BTreeMap::from([(
644 "ambiguous.join".to_string(),
645 serde_json::json!({"fully_qualify_join_types": "outer"}),
646 )]),
647 };
648 let rule = AmbiguousJoinStyle::from_config(&config);
649 let sql = "SELECT foo.a, bar.b FROM foo RIGHT JOIN bar ON foo.id = bar.id";
650 let statements = parse_sql(sql).expect("parse");
651 let issues = rule.check(
652 &statements[0],
653 &LintContext {
654 sql,
655 statement_range: 0..sql.len(),
656 statement_index: 0,
657 },
658 );
659 assert_eq!(issues.len(), 1);
660 }
661
662 #[test]
663 fn outer_mode_allows_right_outer_join() {
664 let config = LintConfig {
665 enabled: true,
666 disabled_rules: vec![],
667 rule_configs: std::collections::BTreeMap::from([(
668 "ambiguous.join".to_string(),
669 serde_json::json!({"fully_qualify_join_types": "outer"}),
670 )]),
671 };
672 let rule = AmbiguousJoinStyle::from_config(&config);
673 let sql = "SELECT foo.a, bar.b FROM foo RIGHT OUTER JOIN bar ON foo.id = bar.id";
674 let statements = parse_sql(sql).expect("parse");
675 let issues = rule.check(
676 &statements[0],
677 &LintContext {
678 sql,
679 statement_range: 0..sql.len(),
680 statement_index: 0,
681 },
682 );
683 assert!(issues.is_empty());
684 }
685
686 #[test]
687 fn outer_mode_flags_full_join_without_outer_keyword() {
688 let config = LintConfig {
689 enabled: true,
690 disabled_rules: vec![],
691 rule_configs: std::collections::BTreeMap::from([(
692 "ambiguous.join".to_string(),
693 serde_json::json!({"fully_qualify_join_types": "outer"}),
694 )]),
695 };
696 let rule = AmbiguousJoinStyle::from_config(&config);
697 let sql = "SELECT foo.a, bar.b FROM foo FULL JOIN bar ON foo.id = bar.id";
698 let statements = parse_sql(sql).expect("parse");
699 let issues = rule.check(
700 &statements[0],
701 &LintContext {
702 sql,
703 statement_range: 0..sql.len(),
704 statement_index: 0,
705 },
706 );
707 assert_eq!(issues.len(), 1);
708 }
709
710 #[test]
711 fn outer_mode_allows_full_outer_join() {
712 let config = LintConfig {
713 enabled: true,
714 disabled_rules: vec![],
715 rule_configs: std::collections::BTreeMap::from([(
716 "ambiguous.join".to_string(),
717 serde_json::json!({"fully_qualify_join_types": "outer"}),
718 )]),
719 };
720 let rule = AmbiguousJoinStyle::from_config(&config);
721 let sql = "SELECT foo.a, bar.b FROM foo FULL OUTER JOIN bar ON foo.id = bar.id";
722 let statements = parse_sql(sql).expect("parse");
723 let issues = rule.check(
724 &statements[0],
725 &LintContext {
726 sql,
727 statement_range: 0..sql.len(),
728 statement_index: 0,
729 },
730 );
731 assert!(issues.is_empty());
732 }
733
734 #[test]
735 fn outer_mode_flags_only_unqualified_full_joins_in_mixed_chains() {
736 let config = LintConfig {
737 enabled: true,
738 disabled_rules: vec![],
739 rule_configs: std::collections::BTreeMap::from([(
740 "ambiguous.join".to_string(),
741 serde_json::json!({"fully_qualify_join_types": "outer"}),
742 )]),
743 };
744 let rule = AmbiguousJoinStyle::from_config(&config);
745 let sql = "SELECT * FROM a FULL JOIN b ON a.id = b.id FULL OUTER JOIN c ON b.id = c.id";
746 let statements = parse_sql(sql).expect("parse");
747 let issues = rule.check(
748 &statements[0],
749 &LintContext {
750 sql,
751 statement_range: 0..sql.len(),
752 statement_index: 0,
753 },
754 );
755 assert_eq!(issues.len(), 1);
756 assert_eq!(issues[0].code, issue_codes::LINT_AM_005);
757 }
758
759 #[test]
760 fn inner_mode_plain_join_emits_safe_autofix_patch() {
761 let sql = "SELECT a FROM t JOIN u ON t.id = u.id";
762 let issues = run(sql);
763 assert_eq!(issues.len(), 1);
764
765 let autofix = issues[0]
766 .autofix
767 .as_ref()
768 .expect("expected AM005 core autofix metadata");
769 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
770 assert_eq!(autofix.edits.len(), 1);
771 assert_eq!(autofix.edits[0].replacement, "INNER JOIN");
772 assert_eq!(
773 &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
774 "JOIN"
775 );
776 }
777
778 #[test]
779 fn outer_mode_full_join_emits_safe_outer_keyword_patch() {
780 let config = LintConfig {
781 enabled: true,
782 disabled_rules: vec![],
783 rule_configs: std::collections::BTreeMap::from([(
784 "ambiguous.join".to_string(),
785 serde_json::json!({"fully_qualify_join_types": "outer"}),
786 )]),
787 };
788 let rule = AmbiguousJoinStyle::from_config(&config);
789 let sql = "SELECT a FROM t FULL JOIN u ON t.id = u.id";
790 let statements = parse_sql(sql).expect("parse");
791 let issues = rule.check(
792 &statements[0],
793 &LintContext {
794 sql,
795 statement_range: 0..sql.len(),
796 statement_index: 0,
797 },
798 );
799 assert_eq!(issues.len(), 1);
800 let autofix = issues[0]
801 .autofix
802 .as_ref()
803 .expect("expected AM005 full join core autofix metadata");
804 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
805 assert_eq!(autofix.edits.len(), 1);
806 assert_eq!(autofix.edits[0].replacement, "OUTER JOIN");
807 assert_eq!(
808 &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
809 "JOIN"
810 );
811 }
812
813 #[test]
814 fn inner_mode_lowercase_join_preserves_case() {
815 let sql = "SELECT a FROM t join u ON t.id = u.id\n";
816 let issues = run(sql);
817 assert_eq!(issues.len(), 1);
818 let autofix = issues[0].autofix.as_ref().expect("expected AM005 autofix");
819 assert_eq!(autofix.edits[0].replacement, "inner join");
820 }
821}