Skip to main content

flowscope_core/linter/rules/
cv_012.rs

1//! LINT_CV_012: JOIN condition convention.
2//!
3//! Plain `JOIN` clauses without ON/USING should use explicit join predicates,
4//! not implicit relationships hidden in WHERE.
5
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::{
9    BinaryOperator, Expr, JoinConstraint, JoinOperator, Select, Spanned, Statement, TableFactor,
10    TableWithJoins,
11};
12use sqlparser::tokenizer::{Span as SqlParserSpan, Token, TokenWithSpan, Tokenizer};
13use std::collections::HashSet;
14
15use super::semantic_helpers::{table_factor_reference_name, visit_selects_in_statement};
16
17pub struct ConventionJoinCondition;
18
19impl LintRule for ConventionJoinCondition {
20    fn code(&self) -> &'static str {
21        issue_codes::LINT_CV_012
22    }
23
24    fn name(&self) -> &'static str {
25        "Join condition convention"
26    }
27
28    fn description(&self) -> &'static str {
29        "Use `JOIN ... ON ...` instead of `WHERE ...` for join conditions."
30    }
31
32    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
33        let mut found_violation = false;
34        let mut autofix_edits: Vec<IssuePatchEdit> = Vec::new();
35
36        visit_selects_in_statement(statement, &mut |select| {
37            let fix_result = cv012_select_autofix_result(select, ctx);
38            if fix_result.has_violation {
39                found_violation = true;
40                autofix_edits.extend(fix_result.edits);
41            }
42        });
43
44        if !found_violation {
45            return Vec::new();
46        }
47
48        sort_and_dedup_patch_edits(&mut autofix_edits);
49        if patch_edits_overlap(&autofix_edits) {
50            autofix_edits.clear();
51        }
52
53        let mut issue = Issue::warning(
54            issue_codes::LINT_CV_012,
55            "JOIN clause appears to lack a meaningful join condition.",
56        )
57        .with_statement(ctx.statement_index);
58        if !autofix_edits.is_empty() {
59            issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
60        }
61        vec![issue]
62    }
63}
64
65#[derive(Default)]
66struct Cv12SelectFixResult {
67    has_violation: bool,
68    edits: Vec<IssuePatchEdit>,
69}
70
71#[derive(Clone)]
72struct Cv12JoinFixPlan {
73    join_index: usize,
74    predicates: Vec<Expr>,
75}
76
77fn cv012_select_autofix_result(select: &Select, ctx: &LintContext) -> Cv12SelectFixResult {
78    let Some(where_expr) = &select.selection else {
79        return Cv12SelectFixResult::default();
80    };
81
82    let select_abs_start = sqlparser_span_abs_offsets(ctx, select.span())
83        .map(|(start, _)| start)
84        .unwrap_or(ctx.statement_range.start);
85
86    let mut has_violation = false;
87    let mut extracted_predicates: Vec<Expr> = Vec::new();
88    let mut edits: Vec<IssuePatchEdit> = Vec::new();
89
90    for table in &select.from {
91        let Some(join_plans) = cv012_plan_join_chain(table, where_expr) else {
92            continue;
93        };
94        has_violation = true;
95
96        for plan in join_plans {
97            if plan.predicates.is_empty() {
98                continue;
99            }
100
101            let Some(join) = table.joins.get(plan.join_index) else {
102                return Cv12SelectFixResult {
103                    has_violation,
104                    edits: Vec::new(),
105                };
106            };
107
108            let Some((_, relation_end_abs)) = sqlparser_span_abs_offsets(ctx, join.relation.span())
109            else {
110                return Cv12SelectFixResult {
111                    has_violation,
112                    edits: Vec::new(),
113                };
114            };
115
116            let Some(on_expr) = combine_predicates_with_and(&plan.predicates) else {
117                continue;
118            };
119
120            edits.push(IssuePatchEdit::new(
121                Span::new(relation_end_abs, relation_end_abs),
122                format!(" ON {on_expr}"),
123            ));
124            extracted_predicates.extend(plan.predicates);
125        }
126    }
127
128    if !has_violation {
129        return Cv12SelectFixResult::default();
130    }
131
132    dedup_expressions(&mut extracted_predicates);
133    if extracted_predicates.is_empty() {
134        return Cv12SelectFixResult {
135            has_violation,
136            edits: Vec::new(),
137        };
138    }
139
140    let Some((where_expr_start, where_expr_end)) =
141        sqlparser_span_statement_offsets(ctx, where_expr.span())
142    else {
143        return Cv12SelectFixResult {
144            has_violation,
145            edits: Vec::new(),
146        };
147    };
148
149    let where_expr_abs_start = ctx.statement_range.start + where_expr_start;
150    let where_expr_abs_end =
151        (ctx.statement_range.start + where_expr_end).min(ctx.statement_range.end);
152
153    let Some(where_keyword_abs_start) =
154        locate_where_keyword_abs_start(ctx, select_abs_start, where_expr_abs_start)
155    else {
156        return Cv12SelectFixResult {
157            has_violation,
158            edits: Vec::new(),
159        };
160    };
161
162    if let Some(remaining_where) =
163        cv012_remove_predicates(where_expr.clone(), &extracted_predicates)
164    {
165        edits.push(IssuePatchEdit::new(
166            Span::new(where_keyword_abs_start, where_expr_abs_end),
167            format!("WHERE {remaining_where}"),
168        ));
169    } else {
170        edits.push(IssuePatchEdit::new(
171            Span::new(where_keyword_abs_start, where_expr_abs_end),
172            String::new(),
173        ));
174    }
175
176    Cv12SelectFixResult {
177        has_violation,
178        edits,
179    }
180}
181
182fn cv012_plan_join_chain(
183    table: &TableWithJoins,
184    where_expr: &Expr,
185) -> Option<Vec<Cv12JoinFixPlan>> {
186    let mut seen_sources = Vec::new();
187    collect_table_factor_sources(&table.relation, &mut seen_sources);
188
189    let mut pass_seen = seen_sources.clone();
190    let mut bare_join_indexes: Vec<(usize, Vec<String>)> = Vec::new();
191
192    for (idx, join) in table.joins.iter().enumerate() {
193        let join_sources = collect_table_factor_all_sources(&join.relation);
194        let Some(constraint) = join_constraint(&join.join_operator) else {
195            pass_seen.extend(join_sources);
196            continue;
197        };
198
199        let has_explicit_join_clause = matches!(
200            constraint,
201            JoinConstraint::On(_) | JoinConstraint::Using(_) | JoinConstraint::Natural
202        );
203        if has_explicit_join_clause {
204            pass_seen.extend(join_sources);
205            continue;
206        }
207
208        let matched_where_predicate = join_sources
209            .iter()
210            .any(|src| where_contains_join_predicate(where_expr, Some(src), &pass_seen))
211            || (join_sources.is_empty()
212                && where_contains_join_predicate(where_expr, None, &pass_seen));
213        if !matched_where_predicate {
214            return None;
215        }
216
217        bare_join_indexes.push((idx, join_sources.clone()));
218        pass_seen.extend(join_sources);
219    }
220
221    if bare_join_indexes.is_empty() {
222        return None;
223    }
224
225    let mut extraction_seen = seen_sources;
226    let mut plans = Vec::new();
227    for (idx, join_sources) in bare_join_indexes {
228        let mut predicates = Vec::new();
229        if join_sources.is_empty() {
230            cv012_collect_extractable_eqs(where_expr, None, &extraction_seen, &mut predicates);
231        } else {
232            for source in &join_sources {
233                cv012_collect_extractable_eqs(
234                    where_expr,
235                    Some(source),
236                    &extraction_seen,
237                    &mut predicates,
238                );
239            }
240        }
241        dedup_expressions(&mut predicates);
242        plans.push(Cv12JoinFixPlan {
243            join_index: idx,
244            predicates,
245        });
246        extraction_seen.extend(join_sources);
247    }
248
249    Some(plans)
250}
251
252fn combine_predicates_with_and(predicates: &[Expr]) -> Option<Expr> {
253    predicates
254        .iter()
255        .cloned()
256        .reduce(|acc, pred| Expr::BinaryOp {
257            left: Box::new(acc),
258            op: BinaryOperator::And,
259            right: Box::new(pred),
260        })
261}
262
263fn dedup_expressions(exprs: &mut Vec<Expr>) {
264    let mut seen = HashSet::new();
265    exprs.retain(|expr| seen.insert(format!("{expr}")));
266}
267
268fn sort_and_dedup_patch_edits(edits: &mut Vec<IssuePatchEdit>) {
269    edits.sort_by(|a, b| {
270        a.span
271            .start
272            .cmp(&b.span.start)
273            .then_with(|| a.span.end.cmp(&b.span.end))
274            .then_with(|| a.replacement.cmp(&b.replacement))
275    });
276    edits.dedup_by(|a, b| a.span == b.span && a.replacement == b.replacement);
277}
278
279fn patch_edits_overlap(edits: &[IssuePatchEdit]) -> bool {
280    edits
281        .windows(2)
282        .any(|pair| pair[0].span.end > pair[1].span.start)
283}
284
285/// Adds the reference name for a table factor to the list.
286fn collect_table_factor_sources(table_factor: &TableFactor, sources: &mut Vec<String>) {
287    if let Some(name) = table_factor_reference_name(table_factor) {
288        sources.push(name);
289    }
290}
291
292/// Collects all table source names from a table factor, including nested join
293/// members. For a simple table reference this returns a single name. For a
294/// `NestedJoin` it returns all inner table names.
295fn collect_table_factor_all_sources(table_factor: &TableFactor) -> Vec<String> {
296    let mut sources = Vec::new();
297    match table_factor {
298        TableFactor::NestedJoin {
299            table_with_joins,
300            alias,
301            ..
302        } => {
303            if let Some(alias) = alias {
304                sources.push(alias.name.value.to_ascii_uppercase());
305            } else {
306                collect_table_factor_sources(&table_with_joins.relation, &mut sources);
307                for join in &table_with_joins.joins {
308                    collect_table_factor_sources(&join.relation, &mut sources);
309                }
310            }
311        }
312        _ => {
313            collect_table_factor_sources(table_factor, &mut sources);
314        }
315    }
316    sources
317}
318
319fn join_constraint(join_operator: &JoinOperator) -> Option<&JoinConstraint> {
320    match join_operator {
321        JoinOperator::Join(constraint)
322        | JoinOperator::Inner(constraint)
323        | JoinOperator::Left(constraint)
324        | JoinOperator::LeftOuter(constraint)
325        | JoinOperator::Right(constraint)
326        | JoinOperator::RightOuter(constraint)
327        | JoinOperator::FullOuter(constraint)
328        | JoinOperator::CrossJoin(constraint)
329        | JoinOperator::Semi(constraint)
330        | JoinOperator::LeftSemi(constraint)
331        | JoinOperator::RightSemi(constraint)
332        | JoinOperator::Anti(constraint)
333        | JoinOperator::LeftAnti(constraint)
334        | JoinOperator::RightAnti(constraint)
335        | JoinOperator::StraightJoin(constraint) => Some(constraint),
336        JoinOperator::AsOf { constraint, .. } => Some(constraint),
337        JoinOperator::CrossApply | JoinOperator::OuterApply => None,
338    }
339}
340
341fn where_contains_join_predicate(
342    expr: &Expr,
343    current_source: Option<&String>,
344    seen_sources: &[String],
345) -> bool {
346    match expr {
347        Expr::BinaryOp { left, op, right } => {
348            let direct_match = matches!(
349                op,
350                BinaryOperator::Eq
351                    | BinaryOperator::NotEq
352                    | BinaryOperator::Lt
353                    | BinaryOperator::Gt
354                    | BinaryOperator::LtEq
355                    | BinaryOperator::GtEq
356            ) && is_column_reference(left)
357                && is_column_reference(right)
358                && references_joined_sources(left, right, current_source, seen_sources);
359
360            direct_match
361                || where_contains_join_predicate(left, current_source, seen_sources)
362                || where_contains_join_predicate(right, current_source, seen_sources)
363        }
364        Expr::UnaryOp { expr: inner, .. }
365        | Expr::Nested(inner)
366        | Expr::IsNull(inner)
367        | Expr::IsNotNull(inner)
368        | Expr::Cast { expr: inner, .. } => {
369            where_contains_join_predicate(inner, current_source, seen_sources)
370        }
371        Expr::InList { expr, list, .. } => {
372            where_contains_join_predicate(expr, current_source, seen_sources)
373                || list
374                    .iter()
375                    .any(|item| where_contains_join_predicate(item, current_source, seen_sources))
376        }
377        Expr::Between {
378            expr, low, high, ..
379        } => {
380            where_contains_join_predicate(expr, current_source, seen_sources)
381                || where_contains_join_predicate(low, current_source, seen_sources)
382                || where_contains_join_predicate(high, current_source, seen_sources)
383        }
384        Expr::Case {
385            operand,
386            conditions,
387            else_result,
388            ..
389        } => {
390            operand.as_ref().is_some_and(|expr| {
391                where_contains_join_predicate(expr, current_source, seen_sources)
392            }) || conditions.iter().any(|when| {
393                where_contains_join_predicate(&when.condition, current_source, seen_sources)
394                    || where_contains_join_predicate(&when.result, current_source, seen_sources)
395            }) || else_result.as_ref().is_some_and(|expr| {
396                where_contains_join_predicate(expr, current_source, seen_sources)
397            })
398        }
399        _ => false,
400    }
401}
402
403fn is_column_reference(expr: &Expr) -> bool {
404    matches!(expr, Expr::Identifier(_) | Expr::CompoundIdentifier(_))
405}
406
407fn references_joined_sources(
408    left: &Expr,
409    right: &Expr,
410    current_source: Option<&String>,
411    seen_sources: &[String],
412) -> bool {
413    let left_prefix = qualifier_prefix(left);
414    let right_prefix = qualifier_prefix(right);
415
416    match (left_prefix, right_prefix, current_source) {
417        (Some(left), Some(right), Some(current)) => {
418            (left == *current && seen_sources.contains(&right))
419                || (right == *current && seen_sources.contains(&left))
420        }
421        // Unqualified `a = b` in a plain join WHERE is still ambiguous and should fail.
422        (None, None, _) => true,
423        _ => false,
424    }
425}
426
427fn qualifier_prefix(expr: &Expr) -> Option<String> {
428    match expr {
429        Expr::CompoundIdentifier(parts) if parts.len() > 1 => {
430            // For `table.col` (2 parts), the qualifier is the first part.
431            // For `schema.table.col` (3+ parts), the qualifier is the
432            // penultimate part (the table name) since that is what
433            // `table_factor_reference_name` extracts as the source name.
434            let qualifier_index = parts.len() - 2;
435            Some(parts[qualifier_index].value.to_ascii_uppercase())
436        }
437        Expr::Nested(inner)
438        | Expr::UnaryOp { expr: inner, .. }
439        | Expr::Cast { expr: inner, .. } => qualifier_prefix(inner),
440        _ => None,
441    }
442}
443
444/// Collect top-level AND-chained equality predicates that join `current_source`
445/// to one of `seen_sources`.
446fn cv012_collect_extractable_eqs(
447    expr: &Expr,
448    current_source: Option<&str>,
449    seen_sources: &[String],
450    out: &mut Vec<Expr>,
451) {
452    match expr {
453        Expr::BinaryOp {
454            left,
455            op: BinaryOperator::And,
456            right,
457        } => {
458            cv012_collect_extractable_eqs(left, current_source, seen_sources, out);
459            cv012_collect_extractable_eqs(right, current_source, seen_sources, out);
460        }
461        Expr::BinaryOp {
462            left,
463            op: BinaryOperator::Eq,
464            right,
465        } => {
466            if cv012_is_extractable_eq(left, right, current_source, seen_sources) {
467                out.push(expr.clone());
468            }
469        }
470        Expr::Nested(inner) => {
471            if let Expr::BinaryOp {
472                left,
473                op: BinaryOperator::Eq,
474                right,
475            } = inner.as_ref()
476            {
477                if cv012_is_extractable_eq(left, right, current_source, seen_sources) {
478                    out.push(expr.clone());
479                }
480            }
481        }
482        _ => {}
483    }
484}
485
486fn cv012_is_extractable_eq(
487    left: &Expr,
488    right: &Expr,
489    current_source: Option<&str>,
490    seen_sources: &[String],
491) -> bool {
492    let Some(current) = current_source else {
493        return false;
494    };
495    let current_upper = current.to_ascii_uppercase();
496    let left_qual = cv012_qualifier(left);
497    let right_qual = cv012_qualifier(right);
498    if let (Some(lq), Some(rq)) = (left_qual, right_qual) {
499        return (lq == current_upper && seen_sources.iter().any(|s| s.to_ascii_uppercase() == rq))
500            || (rq == current_upper && seen_sources.iter().any(|s| s.to_ascii_uppercase() == lq));
501    }
502    false
503}
504
505/// Extract the table qualifier from a column reference expression.
506fn cv012_qualifier(expr: &Expr) -> Option<String> {
507    match expr {
508        Expr::CompoundIdentifier(parts) if parts.len() > 1 => {
509            let qualifier_index = parts.len() - 2;
510            parts
511                .get(qualifier_index)
512                .map(|part| part.value.to_ascii_uppercase())
513        }
514        _ => None,
515    }
516}
517
518/// Remove specific predicates from an AND-chain expression.  Returns `None`
519/// if the entire expression was consumed.
520fn cv012_remove_predicates(expr: Expr, to_remove: &[Expr]) -> Option<Expr> {
521    if to_remove.iter().any(|r| expr_eq(&expr, r)) {
522        return None;
523    }
524    match expr {
525        Expr::BinaryOp {
526            left,
527            op: BinaryOperator::And,
528            right,
529        } => {
530            let left_remaining = cv012_remove_predicates(*left, to_remove);
531            let right_remaining = cv012_remove_predicates(*right, to_remove);
532            match (left_remaining, right_remaining) {
533                (Some(l), Some(r)) => Some(Expr::BinaryOp {
534                    left: Box::new(l),
535                    op: BinaryOperator::And,
536                    right: Box::new(r),
537                }),
538                (Some(l), None) => Some(l),
539                (None, Some(r)) => Some(r),
540                (None, None) => None,
541            }
542        }
543        other => Some(other),
544    }
545}
546
547/// Structural equality check for expressions (used by predicate removal).
548fn expr_eq(a: &Expr, b: &Expr) -> bool {
549    format!("{a}") == format!("{b}")
550}
551
552fn locate_where_keyword_abs_start(
553    ctx: &LintContext,
554    select_abs_start: usize,
555    where_expr_abs_start: usize,
556) -> Option<usize> {
557    let tokens = positioned_statement_tokens(ctx)?;
558    tokens
559        .iter()
560        .filter(|token| {
561            token.start >= select_abs_start
562                && token.end <= where_expr_abs_start
563                && token_word_equals(&token.token, "WHERE")
564        })
565        .map(|token| token.start)
566        .max()
567}
568
569#[derive(Clone)]
570struct PositionedToken {
571    token: Token,
572    start: usize,
573    end: usize,
574}
575
576fn positioned_statement_tokens(ctx: &LintContext) -> Option<Vec<PositionedToken>> {
577    let from_document_tokens = ctx.with_document_tokens(|tokens| {
578        if tokens.is_empty() {
579            return None;
580        }
581
582        let mut positioned = Vec::new();
583        for token in tokens {
584            let (start, end) = token_with_span_offsets(ctx.sql, token)?;
585            if start < ctx.statement_range.start || end > ctx.statement_range.end {
586                continue;
587            }
588            positioned.push(PositionedToken {
589                token: token.token.clone(),
590                start,
591                end,
592            });
593        }
594        Some(positioned)
595    });
596    if let Some(tokens) = from_document_tokens {
597        return Some(tokens);
598    }
599
600    let dialect = ctx.dialect().to_sqlparser_dialect();
601    let mut tokenizer = Tokenizer::new(dialect.as_ref(), ctx.statement_sql());
602    let tokens = tokenizer.tokenize_with_location().ok()?;
603    let mut positioned = Vec::new();
604    for token in tokens {
605        let (start, end) = token_with_span_offsets(ctx.statement_sql(), &token)?;
606        positioned.push(PositionedToken {
607            token: token.token,
608            start: ctx.statement_range.start + start,
609            end: ctx.statement_range.start + end,
610        });
611    }
612    Some(positioned)
613}
614
615fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
616    let start = line_col_to_offset(
617        sql,
618        token.span.start.line as usize,
619        token.span.start.column as usize,
620    )?;
621    let end = line_col_to_offset(
622        sql,
623        token.span.end.line as usize,
624        token.span.end.column as usize,
625    )?;
626    Some((start, end))
627}
628
629fn sqlparser_span_statement_offsets(
630    ctx: &LintContext,
631    span: SqlParserSpan,
632) -> Option<(usize, usize)> {
633    if let Some((start, end)) = sqlparser_span_offsets(ctx.statement_sql(), span) {
634        return Some((start, end));
635    }
636    let (start, end) = sqlparser_span_offsets(ctx.sql, span)?;
637    if start < ctx.statement_range.start || end > ctx.statement_range.end {
638        return None;
639    }
640    Some((
641        start - ctx.statement_range.start,
642        end - ctx.statement_range.start,
643    ))
644}
645
646fn sqlparser_span_abs_offsets(ctx: &LintContext, span: SqlParserSpan) -> Option<(usize, usize)> {
647    if let Some((start, end)) = sqlparser_span_offsets(ctx.statement_sql(), span) {
648        return Some((
649            ctx.statement_range.start + start,
650            ctx.statement_range.start + end,
651        ));
652    }
653    let (start, end) = sqlparser_span_offsets(ctx.sql, span)?;
654    if start < ctx.statement_range.start || end > ctx.statement_range.end {
655        return None;
656    }
657    Some((start, end))
658}
659
660fn sqlparser_span_offsets(sql: &str, span: SqlParserSpan) -> Option<(usize, usize)> {
661    if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
662    {
663        return None;
664    }
665
666    let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
667    let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
668    (end >= start).then_some((start, end))
669}
670
671fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
672    if line == 0 || column == 0 {
673        return None;
674    }
675
676    let mut current_line = 1usize;
677    let mut line_start = 0usize;
678    for (idx, ch) in sql.char_indices() {
679        if current_line == line {
680            break;
681        }
682        if ch == '\n' {
683            current_line += 1;
684            line_start = idx + ch.len_utf8();
685        }
686    }
687    if current_line != line {
688        return None;
689    }
690
691    let mut current_column = 1usize;
692    for (rel_idx, ch) in sql[line_start..].char_indices() {
693        if current_column == column {
694            return Some(line_start + rel_idx);
695        }
696        if ch == '\n' {
697            return None;
698        }
699        current_column += 1;
700    }
701    if current_column == column {
702        return Some(sql.len());
703    }
704    None
705}
706
707fn token_word_equals(token: &Token, word: &str) -> bool {
708    matches!(token, Token::Word(w) if w.value.eq_ignore_ascii_case(word))
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::parser::parse_sql;
715    use crate::types::{IssueAutofixApplicability, IssuePatchEdit};
716
717    fn run(sql: &str) -> Vec<Issue> {
718        let statements = parse_sql(sql).expect("parse");
719        let rule = ConventionJoinCondition;
720        statements
721            .iter()
722            .enumerate()
723            .flat_map(|(index, statement)| {
724                rule.check(
725                    statement,
726                    &LintContext {
727                        sql,
728                        statement_range: 0..sql.len(),
729                        statement_index: index,
730                    },
731                )
732            })
733            .collect()
734    }
735
736    fn apply_edits(sql: &str, edits: &[IssuePatchEdit]) -> String {
737        let mut output = sql.to_string();
738        let mut ordered = edits.iter().collect::<Vec<_>>();
739        ordered.sort_by_key(|edit| edit.span.start);
740        for edit in ordered.into_iter().rev() {
741            output.replace_range(edit.span.start..edit.span.end, &edit.replacement);
742        }
743        output
744    }
745
746    // --- Edge cases adopted from sqlfluff CV12 ---
747
748    #[test]
749    fn allows_plain_join_without_where_clause() {
750        let issues = run("SELECT foo.a, bar.b FROM foo JOIN bar");
751        assert!(issues.is_empty());
752    }
753
754    #[test]
755    fn flags_plain_join_with_implicit_where_predicate() {
756        let issues = run("SELECT foo.a, bar.b FROM foo JOIN bar WHERE foo.x = bar.y");
757        assert_eq!(issues.len(), 1);
758        assert_eq!(issues[0].code, issue_codes::LINT_CV_012);
759    }
760
761    #[test]
762    fn flags_plain_join_with_unqualified_where_predicate() {
763        let issues = run("SELECT foo.a, bar.b FROM foo JOIN bar WHERE a = b");
764        assert_eq!(issues.len(), 1);
765    }
766
767    #[test]
768    fn allows_join_with_explicit_on_clause() {
769        let issues = run("SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON foo.x = bar.x");
770        assert!(issues.is_empty());
771    }
772
773    #[test]
774    fn allows_cross_join() {
775        let issues = run("SELECT foo.a, bar.b FROM foo CROSS JOIN bar WHERE bar.x > 3");
776        assert!(issues.is_empty());
777    }
778
779    #[test]
780    fn flags_inner_join_without_on_with_where_predicate() {
781        let issues = run("SELECT foo.a, bar.b FROM foo INNER JOIN bar WHERE foo.x = bar.y");
782        assert_eq!(issues.len(), 1);
783        assert_eq!(issues[0].code, issue_codes::LINT_CV_012);
784    }
785
786    #[test]
787    fn does_not_flag_multi_join_chain_when_not_all_plain_joins_are_where_joined() {
788        let sql = "select a.id from a join b join c where a.a = b.a and b.b > 1";
789        assert!(run(sql).is_empty());
790    }
791
792    #[test]
793    fn flags_multi_join_chain_when_all_plain_joins_are_where_joined() {
794        let sql = "select a.id from a join b join c where a.a = b.a and b.b = c.b";
795        let issues = run(sql);
796        assert_eq!(issues.len(), 1);
797        assert_eq!(issues[0].code, issue_codes::LINT_CV_012);
798    }
799
800    #[test]
801    fn flags_schema_qualified_where_join() {
802        // SQLFluff: test_fail_missing_clause_and_stmt_qualified
803        let sql = "SELECT foo.a, bar.b FROM schema.foo JOIN schema.bar WHERE schema.foo.x = schema.bar.y AND schema.foo.x = 3";
804        let issues = run(sql);
805        assert_eq!(issues.len(), 1);
806        assert_eq!(issues[0].code, issue_codes::LINT_CV_012);
807    }
808
809    #[test]
810    fn flags_bracketed_join_with_where_predicate() {
811        // SQLFluff: test_fail_join_with_bracketed_join
812        let sql =
813            "SELECT * FROM bar JOIN (foo1 JOIN foo2 ON (foo1.id = foo2.id)) WHERE bar.id = foo1.id";
814        let issues = run(sql);
815        assert_eq!(issues.len(), 1);
816        assert_eq!(issues[0].code, issue_codes::LINT_CV_012);
817    }
818
819    #[test]
820    fn autofix_moves_where_join_predicate_into_on() {
821        let sql = "SELECT foo.a, bar.b FROM foo JOIN bar WHERE foo.x = bar.y";
822        let issues = run(sql);
823        assert_eq!(issues.len(), 1);
824        let autofix = issues[0]
825            .autofix
826            .as_ref()
827            .expect("expected CV12 core autofix metadata");
828        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
829        let fixed = apply_edits(sql, &autofix.edits);
830        assert_eq!(
831            fixed.trim_end(),
832            "SELECT foo.a, bar.b FROM foo JOIN bar ON foo.x = bar.y"
833        );
834    }
835
836    #[test]
837    fn autofix_preserves_non_join_where_predicate() {
838        let sql = "SELECT foo.a, bar.b FROM foo JOIN bar WHERE foo.x = bar.y AND foo.x = 3";
839        let issues = run(sql);
840        let autofix = issues[0]
841            .autofix
842            .as_ref()
843            .expect("expected CV12 core autofix metadata");
844        let fixed = apply_edits(sql, &autofix.edits);
845        assert_eq!(
846            fixed,
847            "SELECT foo.a, bar.b FROM foo JOIN bar ON foo.x = bar.y WHERE foo.x = 3"
848        );
849    }
850
851    #[test]
852    fn autofix_handles_bracketed_join_predicate() {
853        let sql = "SELECT foo.a, bar.b FROM foo JOIN bar WHERE (foo.x = bar.y) AND foo.t = 3";
854        let issues = run(sql);
855        let autofix = issues[0]
856            .autofix
857            .as_ref()
858            .expect("expected CV12 core autofix metadata");
859        let fixed = apply_edits(sql, &autofix.edits);
860        assert_eq!(
861            fixed,
862            "SELECT foo.a, bar.b FROM foo JOIN bar ON (foo.x = bar.y) WHERE foo.t = 3"
863        );
864    }
865
866    #[test]
867    fn autofix_handles_two_bare_joins() {
868        let sql = "SELECT foo.a, bar.b FROM foo JOIN bar JOIN baz WHERE foo.x = bar.y AND foo.x = baz.t AND foo.c = 3";
869        let issues = run(sql);
870        let autofix = issues[0]
871            .autofix
872            .as_ref()
873            .expect("expected CV12 core autofix metadata");
874        let fixed = apply_edits(sql, &autofix.edits);
875        assert_eq!(
876            fixed,
877            "SELECT foo.a, bar.b FROM foo JOIN bar ON foo.x = bar.y JOIN baz ON foo.x = baz.t WHERE foo.c = 3"
878        );
879    }
880
881    #[test]
882    fn autofix_handles_schema_qualified_references() {
883        let sql = "SELECT foo.a, bar.b FROM schema.foo JOIN schema.bar WHERE schema.foo.x = schema.bar.y AND schema.foo.x = 3";
884        let issues = run(sql);
885        let autofix = issues[0]
886            .autofix
887            .as_ref()
888            .expect("expected CV12 core autofix metadata");
889        let fixed = apply_edits(sql, &autofix.edits);
890        assert_eq!(
891            fixed,
892            "SELECT foo.a, bar.b FROM schema.foo JOIN schema.bar ON schema.foo.x = schema.bar.y WHERE schema.foo.x = 3"
893        );
894    }
895}