1use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
9use sqlparser::ast::{BinaryOperator, Expr, Spanned, Statement, TableFactor};
10
11use super::semantic_helpers::{
12 join_on_expr, table_factor_reference_name, visit_selects_in_statement,
13};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16enum PreferredFirstTableInJoinClause {
17 Earlier,
18 Later,
19}
20
21impl PreferredFirstTableInJoinClause {
22 fn from_config(config: &LintConfig) -> Self {
23 match config
24 .rule_option_str(
25 issue_codes::LINT_ST_009,
26 "preferred_first_table_in_join_clause",
27 )
28 .unwrap_or("earlier")
29 .to_ascii_lowercase()
30 .as_str()
31 {
32 "later" => Self::Later,
33 _ => Self::Earlier,
34 }
35 }
36
37 fn left_source<'a>(self, current: &'a str, previous: &'a str) -> &'a str {
38 match self {
39 Self::Earlier => current,
40 Self::Later => previous,
41 }
42 }
43
44 fn right_source<'a>(self, current: &'a str, previous: &'a str) -> &'a str {
45 match self {
46 Self::Earlier => previous,
47 Self::Later => current,
48 }
49 }
50}
51
52pub struct StructureJoinConditionOrder {
53 preferred_first_table: PreferredFirstTableInJoinClause,
54}
55
56impl StructureJoinConditionOrder {
57 pub fn from_config(config: &LintConfig) -> Self {
58 Self {
59 preferred_first_table: PreferredFirstTableInJoinClause::from_config(config),
60 }
61 }
62}
63
64impl Default for StructureJoinConditionOrder {
65 fn default() -> Self {
66 Self {
67 preferred_first_table: PreferredFirstTableInJoinClause::Earlier,
68 }
69 }
70}
71
72impl LintRule for StructureJoinConditionOrder {
73 fn code(&self) -> &'static str {
74 issue_codes::LINT_ST_009
75 }
76
77 fn name(&self) -> &'static str {
78 "Structure join condition order"
79 }
80
81 fn description(&self) -> &'static str {
82 "Joins should list the table referenced earlier/later first."
83 }
84
85 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
86 let mut issues = Vec::new();
87
88 visit_selects_in_statement(statement, &mut |select| {
89 let before = issues.len();
92 'from_loop: for table in &select.from {
93 let mut seen_sources: Vec<String> = Vec::new();
94 check_table_factor_joins(
95 &table.relation,
96 &table.joins,
97 &mut seen_sources,
98 self.preferred_first_table,
99 ctx,
100 &mut issues,
101 );
102 if issues.len() > before {
103 break 'from_loop;
104 }
105 }
106 });
107
108 issues
109 }
110}
111
112fn check_table_factor_joins(
113 relation: &TableFactor,
114 joins: &[sqlparser::ast::Join],
115 seen_sources: &mut Vec<String>,
116 preference: PreferredFirstTableInJoinClause,
117 ctx: &LintContext,
118 issues: &mut Vec<Issue>,
119) {
120 let issues_before = issues.len();
121
122 if let TableFactor::NestedJoin {
124 table_with_joins, ..
125 } = relation
126 {
127 check_table_factor_joins(
128 &table_with_joins.relation,
129 &table_with_joins.joins,
130 seen_sources,
131 preference,
132 ctx,
133 issues,
134 );
135 } else if let Some(base) = table_factor_reference_name(relation) {
136 seen_sources.push(base);
137 }
138
139 for (join_index, join) in joins.iter().enumerate() {
140 if let TableFactor::NestedJoin {
142 table_with_joins, ..
143 } = &join.relation
144 {
145 check_table_factor_joins(
146 &table_with_joins.relation,
147 &table_with_joins.joins,
148 seen_sources,
149 preference,
150 ctx,
151 issues,
152 );
153 }
154
155 let join_name = table_factor_reference_name(&join.relation);
156 if let (Some(current), Some(on_expr)) =
157 (join_name.as_ref(), join_on_expr(&join.join_operator))
158 {
159 if issues.len() == issues_before {
161 let matching_previous = seen_sources
162 .iter()
163 .rev()
164 .find(|candidate| {
165 let left = preference.left_source(current, candidate.as_str());
166 let right = preference.right_source(current, candidate.as_str());
167 has_join_pair(on_expr, left, right)
168 })
169 .cloned();
170
171 if matching_previous.is_some() {
172 let mut issue_span = expr_statement_offsets(ctx, on_expr)
173 .map(|(expr_start, expr_end)| {
174 ctx.span_from_statement_offset(expr_start, expr_end)
175 })
176 .unwrap_or_else(|| Span::new(0, 0));
177 let mut edits: Vec<IssuePatchEdit> = Vec::new();
178
179 if let Some((span, replacement)) =
180 join_condition_autofix_for_sources(ctx, on_expr, current, seen_sources)
181 {
182 issue_span = span;
183 edits.push(IssuePatchEdit::new(span, replacement));
184 }
185
186 let mut seen_for_following = seen_sources.clone();
187 if let Some(name) = join_name.as_ref() {
188 seen_for_following.push(name.clone());
189 }
190 edits.extend(collect_following_join_autofixes(
191 &joins[join_index + 1..],
192 &seen_for_following,
193 preference,
194 ctx,
195 ));
196
197 if issue_span.start != issue_span.end {
198 let mut issue = Issue::info(
199 issue_codes::LINT_ST_009,
200 "Join condition ordering appears inconsistent with configured preference.",
201 )
202 .with_statement(ctx.statement_index)
203 .with_span(issue_span);
204 if !edits.is_empty() {
205 issue =
206 issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
207 }
208 issues.push(issue);
209 }
210 }
211 }
212 }
213
214 if let Some(name) = join_name {
215 seen_sources.push(name);
216 }
217 }
218}
219
220fn collect_following_join_autofixes(
221 joins: &[sqlparser::ast::Join],
222 seen_sources: &[String],
223 preference: PreferredFirstTableInJoinClause,
224 ctx: &LintContext,
225) -> Vec<IssuePatchEdit> {
226 let mut seen = seen_sources.to_vec();
227 let mut edits = Vec::new();
228
229 for join in joins {
230 let join_name = table_factor_reference_name(&join.relation);
231 if let (Some(current), Some(on_expr)) =
232 (join_name.as_ref(), join_on_expr(&join.join_operator))
233 {
234 let matching_previous = seen
235 .iter()
236 .rev()
237 .find(|candidate| {
238 let left = preference.left_source(current, candidate.as_str());
239 let right = preference.right_source(current, candidate.as_str());
240 has_join_pair(on_expr, left, right)
241 })
242 .cloned();
243 if matching_previous.is_some() {
244 if let Some((span, replacement)) =
245 join_condition_autofix_for_sources(ctx, on_expr, current, &seen)
246 {
247 edits.push(IssuePatchEdit::new(span, replacement));
248 }
249 }
250 }
251 if let Some(name) = join_name {
252 seen.push(name);
253 }
254 }
255
256 edits
257}
258
259fn is_comparison_operator(op: &BinaryOperator) -> bool {
260 matches!(
261 op,
262 BinaryOperator::Eq
263 | BinaryOperator::NotEq
264 | BinaryOperator::Lt
265 | BinaryOperator::Gt
266 | BinaryOperator::LtEq
267 | BinaryOperator::GtEq
268 | BinaryOperator::Spaceship
269 )
270}
271
272fn has_join_pair(expr: &Expr, left_source_name: &str, right_source_name: &str) -> bool {
273 match expr {
274 Expr::BinaryOp { left, op, right } => {
275 let direct = if is_comparison_operator(op) {
276 if let (Some(left_prefix), Some(right_prefix)) =
277 (expr_qualified_prefix(left), expr_qualified_prefix(right))
278 {
279 left_prefix == left_source_name && right_prefix == right_source_name
280 } else {
281 false
282 }
283 } else {
284 false
285 };
286
287 direct
288 || has_join_pair(left, left_source_name, right_source_name)
289 || has_join_pair(right, left_source_name, right_source_name)
290 }
291 Expr::UnaryOp { expr: inner, .. }
292 | Expr::Nested(inner)
293 | Expr::IsNull(inner)
294 | Expr::IsNotNull(inner)
295 | Expr::Cast { expr: inner, .. } => {
296 has_join_pair(inner, left_source_name, right_source_name)
297 }
298 Expr::InList { expr, list, .. } => {
299 has_join_pair(expr, left_source_name, right_source_name)
300 || list
301 .iter()
302 .any(|item| has_join_pair(item, left_source_name, right_source_name))
303 }
304 Expr::Between {
305 expr, low, high, ..
306 } => {
307 has_join_pair(expr, left_source_name, right_source_name)
308 || has_join_pair(low, left_source_name, right_source_name)
309 || has_join_pair(high, left_source_name, right_source_name)
310 }
311 Expr::Case {
312 operand,
313 conditions,
314 else_result,
315 ..
316 } => {
317 operand
318 .as_ref()
319 .is_some_and(|operand| has_join_pair(operand, left_source_name, right_source_name))
320 || conditions.iter().any(|when| {
321 has_join_pair(&when.condition, left_source_name, right_source_name)
322 || has_join_pair(&when.result, left_source_name, right_source_name)
323 })
324 || else_result.as_ref().is_some_and(|otherwise| {
325 has_join_pair(otherwise, left_source_name, right_source_name)
326 })
327 }
328 _ => false,
329 }
330}
331
332fn join_condition_autofix_for_sources(
335 ctx: &LintContext,
336 on_expr: &Expr,
337 current_source: &str,
338 previous_sources: &[String],
339) -> Option<(Span, String)> {
340 if previous_sources.is_empty() {
341 return None;
342 }
343 let sql = ctx.statement_sql();
344 let (expr_start, expr_end) = expr_statement_offsets(ctx, on_expr)?;
345 if expr_start > expr_end || expr_end > sql.len() {
346 return None;
347 }
348 let expr_span = ctx.span_from_statement_offset(expr_start, expr_end);
349 let expr_source = &sql[expr_start..expr_end];
350
351 let mut edits: Vec<(usize, usize, String)> = Vec::new();
353 for previous_source in previous_sources {
354 collect_reversed_pair_edits(ctx, on_expr, current_source, previous_source, &mut edits);
355 }
356
357 if edits.is_empty() {
358 return ast_join_condition_autofix_for_sources(
359 on_expr,
360 expr_span,
361 expr_source,
362 current_source,
363 previous_sources,
364 );
365 }
366
367 edits.sort_by_key(|(start, _, _)| *start);
369 edits.dedup_by(|left, right| left.0 == right.0 && left.1 == right.1 && left.2 == right.2);
370
371 let mut result = String::with_capacity(expr_source.len());
374 let mut cursor = expr_start;
375
376 for (edit_start, edit_end, replacement) in &edits {
377 if *edit_start < expr_start
378 || *edit_end > expr_end
379 || *edit_start > *edit_end
380 || *edit_start < cursor
381 {
382 return ast_join_condition_autofix_for_sources(
384 on_expr,
385 expr_span,
386 expr_source,
387 current_source,
388 previous_sources,
389 );
390 }
391 result.push_str(&sql[cursor..*edit_start]);
392 result.push_str(replacement);
393 cursor = *edit_end;
394 }
395 if cursor > expr_end {
396 return ast_join_condition_autofix_for_sources(
397 on_expr,
398 expr_span,
399 expr_source,
400 current_source,
401 previous_sources,
402 );
403 }
404 result.push_str(&sql[cursor..expr_end]);
405
406 Some((expr_span, result))
407}
408
409fn ast_join_condition_autofix_for_sources(
410 on_expr: &Expr,
411 expr_span: Span,
412 expr_source: &str,
413 current_source: &str,
414 previous_sources: &[String],
415) -> Option<(Span, String)> {
416 if expr_source.contains("--") || expr_source.contains("/*") {
418 return None;
419 }
420 if previous_sources.is_empty() {
421 return None;
422 }
423
424 let mut rewritten = on_expr.clone();
425 let mut changed = false;
426 for previous_source in previous_sources {
427 changed |= swap_reversed_pairs_ast(&mut rewritten, current_source, previous_source);
428 }
429 if !changed {
430 return None;
431 }
432 let replacement = rewritten.to_string();
433 if replacement == expr_source {
434 return None;
435 }
436 Some((expr_span, replacement))
437}
438
439fn swap_reversed_pairs_ast(expr: &mut Expr, current_source: &str, previous_source: &str) -> bool {
440 match expr {
441 Expr::BinaryOp { left, op, right } => {
442 if is_comparison_operator(op) {
443 let left_prefix = expr_qualified_prefix(left);
444 let right_prefix = expr_qualified_prefix(right);
445 if left_prefix.as_deref() == Some(current_source)
446 && right_prefix.as_deref() == Some(previous_source)
447 {
448 std::mem::swap(left, right);
449 *op = flipped_comparison_operator(op);
450 return true;
451 }
452 }
453
454 let left_changed = swap_reversed_pairs_ast(left, current_source, previous_source);
455 let right_changed = swap_reversed_pairs_ast(right, current_source, previous_source);
456 left_changed || right_changed
457 }
458 Expr::UnaryOp { expr: inner, .. }
459 | Expr::Nested(inner)
460 | Expr::IsNull(inner)
461 | Expr::IsNotNull(inner)
462 | Expr::IsTrue(inner)
463 | Expr::IsNotTrue(inner)
464 | Expr::IsFalse(inner)
465 | Expr::IsNotFalse(inner)
466 | Expr::IsUnknown(inner)
467 | Expr::IsNotUnknown(inner)
468 | Expr::Cast { expr: inner, .. } => {
469 swap_reversed_pairs_ast(inner, current_source, previous_source)
470 }
471 Expr::InList {
472 expr: target, list, ..
473 } => {
474 let mut changed = swap_reversed_pairs_ast(target, current_source, previous_source);
475 for item in list {
476 changed |= swap_reversed_pairs_ast(item, current_source, previous_source);
477 }
478 changed
479 }
480 Expr::Between {
481 expr: target,
482 low,
483 high,
484 ..
485 } => {
486 swap_reversed_pairs_ast(target, current_source, previous_source)
487 | swap_reversed_pairs_ast(low, current_source, previous_source)
488 | swap_reversed_pairs_ast(high, current_source, previous_source)
489 }
490 Expr::Case {
491 operand,
492 conditions,
493 else_result,
494 ..
495 } => {
496 let mut changed = false;
497 if let Some(operand) = operand {
498 changed |= swap_reversed_pairs_ast(operand, current_source, previous_source);
499 }
500 for case_when in conditions {
501 changed |= swap_reversed_pairs_ast(
502 &mut case_when.condition,
503 current_source,
504 previous_source,
505 );
506 changed |=
507 swap_reversed_pairs_ast(&mut case_when.result, current_source, previous_source);
508 }
509 if let Some(else_result) = else_result {
510 changed |= swap_reversed_pairs_ast(else_result, current_source, previous_source);
511 }
512 changed
513 }
514 _ => false,
515 }
516}
517
518fn flipped_comparison_operator(op: &BinaryOperator) -> BinaryOperator {
519 match op {
520 BinaryOperator::Lt => BinaryOperator::Gt,
521 BinaryOperator::Gt => BinaryOperator::Lt,
522 BinaryOperator::LtEq => BinaryOperator::GtEq,
523 BinaryOperator::GtEq => BinaryOperator::LtEq,
524 _ => op.clone(),
525 }
526}
527
528fn collect_reversed_pair_edits(
532 ctx: &LintContext,
533 expr: &Expr,
534 current_source: &str,
535 previous_source: &str,
536 edits: &mut Vec<(usize, usize, String)>,
537) {
538 let sql = ctx.statement_sql();
539
540 match expr {
541 Expr::BinaryOp { left, op, right } => {
542 if is_comparison_operator(op) {
543 let left_prefix = expr_qualified_prefix(left);
544 let right_prefix = expr_qualified_prefix(right);
545 if left_prefix.as_deref() == Some(current_source)
546 && right_prefix.as_deref() == Some(previous_source)
547 {
548 if let (Some((l_start, l_end)), Some((r_start, r_end))) = (
550 expr_statement_offsets(ctx, left),
551 expr_statement_offsets(ctx, right),
552 ) {
553 if l_start <= l_end
554 && l_end <= r_start
555 && r_start <= r_end
556 && r_end <= sql.len()
557 {
558 let gap = &sql[l_end..r_start];
559 if gap.contains("--") || gap.contains("/*") {
562 return;
563 }
564 let left_text = &sql[l_start..l_end];
565 let right_text = &sql[r_start..r_end];
566 let op_text = flip_operator_text(gap, op);
567
568 let replacement = format!("{right_text}{op_text}{left_text}");
570 edits.push((l_start, r_end, replacement));
571 return; }
573 }
574 }
575 }
576
577 collect_reversed_pair_edits(ctx, left, current_source, previous_source, edits);
579 collect_reversed_pair_edits(ctx, right, current_source, previous_source, edits);
580 }
581 Expr::UnaryOp { expr: inner, .. }
582 | Expr::Nested(inner)
583 | Expr::IsNull(inner)
584 | Expr::IsNotNull(inner)
585 | Expr::IsTrue(inner)
586 | Expr::IsNotTrue(inner)
587 | Expr::IsFalse(inner)
588 | Expr::IsNotFalse(inner)
589 | Expr::IsUnknown(inner)
590 | Expr::IsNotUnknown(inner)
591 | Expr::Cast { expr: inner, .. } => {
592 collect_reversed_pair_edits(ctx, inner, current_source, previous_source, edits)
593 }
594 Expr::InList {
595 expr: target, list, ..
596 } => {
597 collect_reversed_pair_edits(ctx, target, current_source, previous_source, edits);
598 for item in list {
599 collect_reversed_pair_edits(ctx, item, current_source, previous_source, edits);
600 }
601 }
602 Expr::Between {
603 expr: target,
604 low,
605 high,
606 ..
607 } => {
608 collect_reversed_pair_edits(ctx, target, current_source, previous_source, edits);
609 collect_reversed_pair_edits(ctx, low, current_source, previous_source, edits);
610 collect_reversed_pair_edits(ctx, high, current_source, previous_source, edits);
611 }
612 Expr::Case {
613 operand,
614 conditions,
615 else_result,
616 ..
617 } => {
618 if let Some(operand) = operand {
619 collect_reversed_pair_edits(ctx, operand, current_source, previous_source, edits);
620 }
621 for case_when in conditions {
622 collect_reversed_pair_edits(
623 ctx,
624 &case_when.condition,
625 current_source,
626 previous_source,
627 edits,
628 );
629 collect_reversed_pair_edits(
630 ctx,
631 &case_when.result,
632 current_source,
633 previous_source,
634 edits,
635 );
636 }
637 if let Some(else_result) = else_result {
638 collect_reversed_pair_edits(
639 ctx,
640 else_result,
641 current_source,
642 previous_source,
643 edits,
644 );
645 }
646 }
647 _ => {}
648 }
649}
650
651fn flip_operator_text(gap: &str, op: &BinaryOperator) -> String {
655 match op {
656 BinaryOperator::Eq | BinaryOperator::NotEq | BinaryOperator::Spaceship => gap.to_string(),
658 BinaryOperator::Lt => gap.replacen('<', ">", 1),
660 BinaryOperator::Gt => gap.replacen('>', "<", 1),
661 BinaryOperator::LtEq => gap.replacen("<=", ">=", 1),
662 BinaryOperator::GtEq => gap.replacen(">=", "<=", 1),
663 _ => gap.to_string(),
664 }
665}
666
667fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> {
668 if ctx.statement_range.start > 0 {
673 if let Some((start, end)) = expr_span_offsets(ctx.sql, expr) {
674 if start >= ctx.statement_range.start && end <= ctx.statement_range.end {
675 return Some((
676 start - ctx.statement_range.start,
677 end - ctx.statement_range.start,
678 ));
679 }
680 }
681 }
682
683 if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) {
684 return Some((start, end));
685 }
686
687 let (start, end) = expr_span_offsets(ctx.sql, expr)?;
688 if start < ctx.statement_range.start || end > ctx.statement_range.end {
689 return None;
690 }
691 Some((
692 start - ctx.statement_range.start,
693 end - ctx.statement_range.start,
694 ))
695}
696
697fn expr_span_offsets(sql: &str, expr: &Expr) -> Option<(usize, usize)> {
698 let span = expr.span();
699 if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
700 {
701 return None;
702 }
703
704 let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
705 let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
706 (end >= start).then_some((start, end))
707}
708
709fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
710 if line == 0 || column == 0 {
711 return None;
712 }
713
714 let mut current_line = 1usize;
715 let mut line_start = 0usize;
716
717 for (idx, ch) in sql.char_indices() {
718 if current_line == line {
719 break;
720 }
721 if ch == '\n' {
722 current_line += 1;
723 line_start = idx + ch.len_utf8();
724 }
725 }
726 if current_line != line {
727 return None;
728 }
729
730 let mut current_column = 1usize;
731 for (rel_idx, ch) in sql[line_start..].char_indices() {
732 if current_column == column {
733 return Some(line_start + rel_idx);
734 }
735 if ch == '\n' {
736 return None;
737 }
738 current_column += 1;
739 }
740
741 if current_column == column {
742 return Some(sql.len());
743 }
744
745 None
746}
747
748fn normalize_source_name(name: &str) -> String {
749 name.trim_matches(|ch| matches!(ch, '"' | '`' | '\'' | '[' | ']'))
750 .to_ascii_uppercase()
751}
752
753fn expr_qualified_prefix(expr: &Expr) -> Option<String> {
754 match expr {
755 Expr::CompoundIdentifier(parts) if parts.len() > 1 => parts
756 .get(parts.len().saturating_sub(2))
757 .map(|ident| normalize_source_name(&ident.value)),
758 Expr::Nested(inner)
759 | Expr::UnaryOp { expr: inner, .. }
760 | Expr::Cast { expr: inner, .. } => expr_qualified_prefix(inner),
761 _ => None,
762 }
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::parser::parse_sql;
769 use crate::types::{IssueAutofixApplicability, IssuePatchEdit};
770
771 fn run(sql: &str) -> Vec<Issue> {
772 let statements = parse_sql(sql).expect("parse");
773 let rule = StructureJoinConditionOrder::default();
774 statements
775 .iter()
776 .enumerate()
777 .flat_map(|(index, statement)| {
778 rule.check(
779 statement,
780 &LintContext {
781 sql,
782 statement_range: 0..sql.len(),
783 statement_index: index,
784 },
785 )
786 })
787 .collect()
788 }
789
790 fn apply_edits(sql: &str, edits: &[IssuePatchEdit]) -> String {
791 let mut output = sql.to_string();
792 let mut ordered = edits.iter().collect::<Vec<_>>();
793 ordered.sort_by_key(|edit| edit.span.start);
794
795 for edit in ordered.into_iter().rev() {
796 output.replace_range(edit.span.start..edit.span.end, &edit.replacement);
797 }
798
799 output
800 }
801
802 #[test]
805 fn allows_queries_without_joins() {
806 let issues = run("select * from foo");
807 assert!(issues.is_empty());
808 }
809
810 #[test]
811 fn allows_expected_source_order_in_join_condition() {
812 let issues = run("select foo.a, bar.b from foo left join bar on foo.a = bar.a");
813 assert!(issues.is_empty());
814 }
815
816 #[test]
817 fn flags_reversed_source_order_in_join_condition() {
818 let issues = run("select foo.a, bar.b from foo left join bar on bar.a = foo.a");
819 assert_eq!(issues.len(), 1);
820 assert_eq!(issues[0].code, issue_codes::LINT_ST_009);
821
822 let autofix = issues[0]
823 .autofix
824 .as_ref()
825 .expect("expected ST009 core autofix metadata");
826 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
827 assert_eq!(autofix.edits.len(), 1);
828 let fixed = apply_edits(
829 "select foo.a, bar.b from foo left join bar on bar.a = foo.a",
830 &autofix.edits,
831 );
832 assert_eq!(
833 fixed,
834 "select foo.a, bar.b from foo left join bar on foo.a = bar.a"
835 );
836 }
837
838 #[test]
839 fn allows_unqualified_reference_side() {
840 let issues = run("select foo.a, bar.b from foo left join bar on bar.b = a");
841 assert!(issues.is_empty());
842 }
843
844 #[test]
845 fn flags_multiple_reversed_subconditions() {
846 let issues = run(
847 "select foo.a, foo.b, bar.c from foo left join bar on bar.a = foo.a and bar.b = foo.b",
848 );
849 assert_eq!(issues.len(), 1);
850 }
851
852 #[test]
853 fn later_preference_flags_earlier_on_left_side() {
854 let config = LintConfig {
855 enabled: true,
856 disabled_rules: vec![],
857 rule_configs: std::collections::BTreeMap::from([(
858 "structure.join_condition_order".to_string(),
859 serde_json::json!({"preferred_first_table_in_join_clause": "later"}),
860 )]),
861 };
862 let rule = StructureJoinConditionOrder::from_config(&config);
863 let sql = "select foo.a, bar.b from foo left join bar on foo.a = bar.a";
864 let statements = parse_sql(sql).expect("parse");
865 let issues = rule.check(
866 &statements[0],
867 &LintContext {
868 sql,
869 statement_range: 0..sql.len(),
870 statement_index: 0,
871 },
872 );
873 assert_eq!(issues.len(), 1);
874 assert_eq!(issues[0].code, issue_codes::LINT_ST_009);
875 }
876
877 #[test]
878 fn comment_in_join_condition_blocks_safe_autofix_metadata() {
879 let sql = "select foo.a, bar.b from foo left join bar on bar.a /*keep*/ = foo.a";
880 let issues = run(sql);
881 assert_eq!(issues.len(), 1);
882 assert!(
883 issues[0].autofix.is_none(),
884 "comment-bearing join condition should not emit ST009 safe patch metadata"
885 );
886 }
887
888 #[test]
889 fn flags_reversed_non_equality_comparison_operators() {
890 let sql = "select foo.a, bar.b from foo left join bar on bar.a != foo.a and bar.b > foo.b and bar.c <= foo.c";
892 let issues = run(sql);
893 assert_eq!(issues.len(), 1);
894 assert_eq!(issues[0].code, issue_codes::LINT_ST_009);
895 }
896
897 #[test]
898 fn flags_reversed_join_inside_bracketed_from() {
899 let sql = "select foo.a, bar.b from (foo left join bar on bar.a = foo.a)";
901 let issues = run(sql);
902 assert_eq!(issues.len(), 1);
903 assert_eq!(issues[0].code, issue_codes::LINT_ST_009);
904 }
905
906 #[test]
907 fn flags_spaceship_operator_reversed() {
908 let sql = "SELECT bt.test FROM base_table AS bt INNER JOIN second_table AS st ON st.test <=> bt.test";
910 let issues = run(sql);
911 assert_eq!(issues.len(), 1);
912 assert_eq!(issues[0].code, issue_codes::LINT_ST_009);
913 }
914
915 #[test]
916 fn autofix_preserves_parentheses_around_condition() {
917 let sql = "select foo.a, bar.b from foo left join bar on (bar.a = foo.a)";
919 let issues = run(sql);
920 assert_eq!(issues.len(), 1);
921 let fixed = apply_edits(sql, &issues[0].autofix.as_ref().unwrap().edits);
922 assert_eq!(
923 fixed,
924 "select foo.a, bar.b from foo left join bar on (foo.a = bar.a)"
925 );
926 }
927
928 #[test]
929 fn autofix_preserves_multiline_formatting_and_keyword_case() {
930 let sql = "select\n foo.a,\n foo.b,\n bar.c\nfrom foo\nleft join bar\n on bar.a = foo.a\n and bar.b = foo.b\n";
932 let issues = run(sql);
933 assert_eq!(issues.len(), 1);
934 let fixed = apply_edits(sql, &issues[0].autofix.as_ref().unwrap().edits);
935 assert_eq!(
936 fixed,
937 "select\n foo.a,\n foo.b,\n bar.c\nfrom foo\nleft join bar\n on foo.a = bar.a\n and foo.b = bar.b\n"
938 );
939 }
940
941 #[test]
942 fn autofix_flips_directional_comparison_operators() {
943 let sql = "select foo.a, bar.b from foo left join bar on bar.a != foo.a and bar.b > foo.b and bar.c <= foo.c";
945 let issues = run(sql);
946 assert_eq!(issues.len(), 1);
947 let fixed = apply_edits(sql, &issues[0].autofix.as_ref().unwrap().edits);
948 assert_eq!(
949 fixed,
950 "select foo.a, bar.b from foo left join bar on foo.a != bar.a and foo.b < bar.b and foo.c >= bar.c"
951 );
952 }
953
954 #[test]
955 fn autofix_preserves_quoted_identifiers() {
956 let sql = "select\n \"foo\".\"a\",\n \"bar\".\"b\"\nfrom foo\nleft join \"bar\"\n on \"bar\".\"a\" = \"foo\".\"a\"\n and \"bar\".\"b\" = foo.\"b\"\n";
958 let issues = run(sql);
959 assert_eq!(issues.len(), 1);
960 let fixed = apply_edits(sql, &issues[0].autofix.as_ref().unwrap().edits);
961 assert_eq!(
962 fixed,
963 "select\n \"foo\".\"a\",\n \"bar\".\"b\"\nfrom foo\nleft join \"bar\"\n on \"foo\".\"a\" = \"bar\".\"a\"\n and foo.\"b\" = \"bar\".\"b\"\n"
964 );
965 }
966
967 #[test]
968 fn autofix_reorders_join_conditions_against_any_seen_source_in_chain() {
969 let sql = "select\n foo.a,\n bar.b,\n baz.c\nfrom foo\nleft join bar\n on bar.a != foo.a\n and bar.b > foo.b\n and bar.c <= foo.c\nleft join baz\n on baz.a <> foo.a\n and baz.b >= foo.b\n and baz.c < foo.c\n";
971 let issues = run(sql);
972 assert_eq!(issues.len(), 1);
974 let fixed = apply_edits(sql, &issues[0].autofix.as_ref().unwrap().edits);
975 assert_eq!(
976 fixed,
977 "select\n foo.a,\n bar.b,\n baz.c\nfrom foo\nleft join bar\n on foo.a != bar.a\n and foo.b < bar.b\n and foo.c >= bar.c\nleft join baz\n on foo.a <> baz.a\n and foo.b <= baz.b\n and foo.c > baz.c\n"
978 );
979 }
980
981 #[test]
982 fn autofix_handles_reversed_join_with_additional_same_table_filter() {
983 let sql = "select wur.id from ledger.work_unit_run as wur inner join ledger.work_unit as wu on wu.id = wur.work_unit_id and wu.type = 'job'";
984 let issues = run(sql);
985 assert_eq!(issues.len(), 1);
986
987 let autofix = issues[0]
988 .autofix
989 .as_ref()
990 .expect("expected ST009 autofix metadata");
991 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
992 assert!(!autofix.edits.is_empty());
993
994 let fixed = apply_edits(sql, &autofix.edits);
995 assert!(
996 fixed.contains("wur.work_unit_id = wu.id"),
997 "expected reordered join predicate, got: {fixed}"
998 );
999 assert!(
1000 fixed.contains("wu.type = 'job'"),
1001 "expected same-table predicate to be preserved, got: {fixed}"
1002 );
1003 }
1004
1005 #[test]
1006 fn emits_autofix_for_reversed_workspace_join_after_ordered_prior_join() {
1007 let sql = "select wur.id from ledger.work_unit_run as wur join ledger.work_unit as wu on wur.work_unit_id = wu.id and wu.type = 'job' inner join ledger.workspace as ws on ws.id = wu.workspace_id left join ledger.usage_line_item as uli on uli.job_run_id = wur.external_run_id";
1008 let issues = run(sql);
1009 assert_eq!(issues.len(), 1);
1010
1011 let autofix = issues[0]
1012 .autofix
1013 .as_ref()
1014 .expect("expected ST009 autofix metadata");
1015 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1016
1017 let fixed = apply_edits(sql, &autofix.edits);
1018 assert!(
1019 fixed.contains("wu.workspace_id = ws.id"),
1020 "expected reordered workspace join predicate, got: {fixed}"
1021 );
1022 }
1023
1024 #[test]
1025 fn emits_autofix_for_schema_qualified_reversed_join_condition() {
1026 let sql = "select ledger.work_unit_run.id from ledger.work_unit_run join ledger.work_unit on ledger.work_unit.id = ledger.work_unit_run.work_unit_id";
1027 let issues = run(sql);
1028 assert_eq!(issues.len(), 1);
1029
1030 let autofix = issues[0]
1031 .autofix
1032 .as_ref()
1033 .expect("expected ST009 autofix metadata");
1034 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1035 assert!(!autofix.edits.is_empty());
1036
1037 let fixed = apply_edits(sql, &autofix.edits);
1038 assert_eq!(
1039 fixed,
1040 "select ledger.work_unit_run.id from ledger.work_unit_run join ledger.work_unit on ledger.work_unit_run.work_unit_id = ledger.work_unit.id"
1041 );
1042 }
1043
1044 #[test]
1045 fn emits_autofix_for_quoted_schema_qualified_reversed_join_condition() {
1046 let sql = "select \"ledger\".\"work_unit_run\".\"id\" from \"ledger\".\"work_unit_run\" join \"ledger\".\"work_unit\" on \"ledger\".\"work_unit\".\"id\" = \"ledger\".\"work_unit_run\".\"work_unit_id\"";
1047 let issues = run(sql);
1048 assert_eq!(issues.len(), 1);
1049
1050 let autofix = issues[0]
1051 .autofix
1052 .as_ref()
1053 .expect("expected ST009 autofix metadata");
1054 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1055 assert!(!autofix.edits.is_empty());
1056
1057 let fixed = apply_edits(sql, &autofix.edits);
1058 assert_eq!(
1059 fixed,
1060 "select \"ledger\".\"work_unit_run\".\"id\" from \"ledger\".\"work_unit_run\" join \"ledger\".\"work_unit\" on \"ledger\".\"work_unit_run\".\"work_unit_id\" = \"ledger\".\"work_unit\".\"id\""
1061 );
1062 }
1063
1064 #[test]
1065 fn emits_autofix_for_reversed_join_inside_cte_with_additional_join() {
1066 let sql = "WITH active_jobs AS (\n SELECT wu.id, wur.id\n FROM raw.lakeflow_jobs AS j\n INNER JOIN ledger.work_unit AS wu ON wu.external_id = j.job_id AND wu.type = 'job'\n INNER JOIN ledger.work_unit_run AS wur ON wur.work_unit_id = wu.id\n)\nSELECT * FROM active_jobs";
1067 let issues = run(sql);
1068 assert_eq!(issues.len(), 1);
1069 let autofix = issues[0]
1070 .autofix
1071 .as_ref()
1072 .expect("expected ST009 autofix metadata");
1073 let fixed = apply_edits(sql, &autofix.edits);
1074 assert!(
1075 fixed.contains("j.job_id = wu.external_id"),
1076 "expected first join to be reordered, got: {fixed}"
1077 );
1078 assert!(
1079 fixed.contains("wu.id = wur.work_unit_id"),
1080 "expected second join to be reordered in same patch, got: {fixed}"
1081 );
1082 }
1083
1084 #[test]
1085 fn emits_autofix_for_workspace_join_after_inner_chain() {
1086 let sql = "SELECT wur.id\nFROM ledger.work_unit_run AS wur\nINNER JOIN ledger.work_unit AS wu ON wur.work_unit_id = wu.id AND wu.type = 'job'\nINNER JOIN ledger.workspace AS ws ON ws.id = wu.workspace_id";
1087 let issues = run(sql);
1088 assert_eq!(issues.len(), 1);
1089 let autofix = issues[0]
1090 .autofix
1091 .as_ref()
1092 .expect("expected ST009 autofix metadata");
1093 let fixed = apply_edits(sql, &autofix.edits);
1094 assert!(
1095 fixed.contains("wu.workspace_id = ws.id"),
1096 "expected workspace join predicate reorder, got: {fixed}"
1097 );
1098 }
1099
1100 #[test]
1101 fn emits_autofix_for_workspace_join_inside_cte_chain() {
1102 let sql = "WITH job_run_costs AS (\n SELECT\n wur.id AS work_unit_run_id,\n wu.workspace_id\n FROM ledger.work_unit_run AS wur\n INNER\n JOIN ledger.work_unit AS wu ON wur.work_unit_id = wu.id AND wu.type = 'job'\n INNER JOIN ledger.workspace AS ws ON ws.id = wu.workspace_id\n)\nSELECT * FROM job_run_costs";
1103 let issues = run(sql);
1104 assert_eq!(issues.len(), 1);
1105 assert!(
1106 issues[0].autofix.is_some(),
1107 "expected ST009 autofix metadata for CTE join chain"
1108 );
1109 let fixed = apply_edits(
1110 sql,
1111 &issues[0]
1112 .autofix
1113 .as_ref()
1114 .expect("expected ST009 autofix metadata")
1115 .edits,
1116 );
1117 assert!(
1118 fixed.contains("wu.workspace_id = ws.id"),
1119 "expected workspace join predicate reorder, got: {fixed}"
1120 );
1121 }
1122
1123 #[test]
1124 fn autofix_handles_insert_select_with_on_conflict_join_chain() {
1125 let sql = "INSERT INTO metrics.page_performance_summary (\n route,\n period_start,\n period_end,\n nav_type,\n mark\n)\nSELECT\n o.route,\n p.period_start,\n p.period_end,\n o.nav_type,\n o.mark\nFROM overall AS o\nCROSS JOIN params AS p\nLEFT JOIN device_breakdown AS d\n ON d.route = o.route AND d.nav_type = o.nav_type AND d.mark = o.mark\nLEFT JOIN network_breakdown AS n\n ON n.route = o.route AND n.nav_type = o.nav_type AND n.mark = o.mark\nLEFT JOIN version_breakdown AS v\n ON v.route = o.route AND v.nav_type = o.nav_type AND v.mark = o.mark\nON CONFLICT (route, period_start, nav_type, mark) DO UPDATE SET\n period_end = excluded.period_end;\n";
1126 let issues = run(sql);
1127 assert_eq!(issues.len(), 1);
1128 let autofix = issues[0]
1129 .autofix
1130 .as_ref()
1131 .expect("expected ST009 autofix metadata");
1132 let fixed = apply_edits(sql, &autofix.edits);
1133 assert!(
1134 fixed.contains("ON o.route = d.route AND o.nav_type = d.nav_type AND o.mark = d.mark"),
1135 "expected first join reordered, got: {fixed}"
1136 );
1137 assert!(
1138 fixed.contains("ON o.route = n.route AND o.nav_type = n.nav_type AND o.mark = n.mark"),
1139 "expected second join reordered, got: {fixed}"
1140 );
1141 assert!(
1142 fixed.contains("ON o.route = v.route AND o.nav_type = v.nav_type AND o.mark = v.mark"),
1143 "expected third join reordered, got: {fixed}"
1144 );
1145 assert!(
1146 fixed.contains("ON CONFLICT (route, period_start, nav_type, mark) DO UPDATE SET"),
1147 "expected ON CONFLICT clause preserved, got: {fixed}"
1148 );
1149 }
1150}