Skip to main content

flowscope_core/linter/rules/
am_005.rs

1//! LINT_AM_005: Ambiguous JOIN style.
2//!
3//! Require explicit JOIN type keywords (`INNER`, `LEFT`, etc.) instead of bare
4//! `JOIN` for clearer intent.
5
6use 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            // Insert OUTER keyword before JOIN, preserving case.
237            // Only replace the JOIN token span with "OUTER JOIN" to avoid
238            // expanding the edit span over the side keyword (LEFT/RIGHT/FULL).
239            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    // --- Edge cases adopted from sqlfluff AM05 ---
519
520    #[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}