1use 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
285fn 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
292fn 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 (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 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
444fn 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
505fn 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
518fn 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
547fn 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 #[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 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 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}