Skip to main content

flowscope_core/linter/rules/
st_009.rs

1//! LINT_ST_009: Reversed JOIN condition ordering.
2//!
3//! Detect predicates where the newly joined relation appears on the left side
4//! and prior relation on the right side (e.g. `o.user_id = u.id`).
5
6use 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            // SQLFluff ST09 parity: report at most one reversed-join issue
90            // per SELECT, matching SQLFluff's first-violation-only behaviour.
91            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    // For NestedJoin, recurse into the inner table_with_joins.
123    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        // Recurse into nested join relations on the right side.
141        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            // SQLFluff parity: only flag the first reversed join per FROM clause.
160            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
332/// Produce source-text-level edits that swap individual reversed comparison
333/// pairs while preserving original formatting, quoting, and keyword casing.
334fn 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    // Collect text-level edits for each reversed pair.
352    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    // Sort edits by position (ascending) for deterministic application.
368    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    // Build replacement by applying text edits to the overall ON expression
372    // source span.
373    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            // Overlapping edits — bail out to avoid corruption.
383            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    // AST rendering would drop comments; avoid this fallback on commented ON clauses.
417    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
528/// Walk the AST and collect source-text edits for each reversed comparison
529/// pair. Each edit replaces `left op right` with `right flipped_op left`
530/// using the original source text for both operands.
531fn 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                    // Extract source text for left and right operands.
549                    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                            // Skip if the gap contains a comment — swapping could
560                            // misplace or corrupt it.
561                            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                            // Replace the entire `left op right` span with `right op left`.
569                            let replacement = format!("{right_text}{op_text}{left_text}");
570                            edits.push((l_start, r_end, replacement));
571                            return; // Don't recurse into children we just handled.
572                        }
573                    }
574                }
575            }
576
577            // Recurse into children for logical connectives (AND, OR).
578            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
651/// Given the source text between the left and right operands (which contains
652/// whitespace + operator + whitespace), return it with the operator flipped
653/// for directional comparison operators.
654fn flip_operator_text(gap: &str, op: &BinaryOperator) -> String {
655    match op {
656        // Symmetric operators — no change needed.
657        BinaryOperator::Eq | BinaryOperator::NotEq | BinaryOperator::Spaceship => gap.to_string(),
658        // Directional operators — flip the operator while preserving surrounding whitespace.
659        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    // Statement ranges may intentionally trim leading comments/whitespace.
669    // SQLParser span line/column coordinates are often absolute to the
670    // original document, so prefer document-level offset conversion when the
671    // statement does not start at byte 0.
672    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    // --- Edge cases adopted from sqlfluff ST09 ---
803
804    #[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        // SQLFluff: test_fail_later_table_first_multiple_comparison_operators
891        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        // SQLFluff: test_fail_later_table_first_brackets_after_from
900        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        // SQLFluff: test_fail_sparksql_lt_eq_gt_operator
909        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        // SQLFluff: test_fail_later_table_first_brackets_after_on
918        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        // SQLFluff: test_fail_later_table_first_multiple_subconditions
931        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        // SQLFluff: test_fail_later_table_first_multiple_comparison_operators (single join)
944        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        // SQLFluff: test_fail_later_table_first_quoted_table_not_columns
957        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        // SQLFluff: ST09 flags only the first reversed join per SELECT.
970        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        // Only the first reversed join (bar) is reported per SELECT.
973        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}