Skip to main content

rigsql_rules/convention/
cv07.rs

1use rigsql_core::{Segment, SegmentType};
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::LintViolation;
5
6/// CV07: Prefer COALESCE over CASE with IS NULL.
7///
8/// A CASE expression that tests a single column with IS NULL in the WHEN
9/// clause and returns that column in the ELSE clause can be simplified
10/// to COALESCE.
11///
12/// Example:
13/// ```sql
14/// -- Flagged:
15/// CASE WHEN x IS NULL THEN y ELSE x END
16/// -- Preferred:
17/// COALESCE(x, y)
18/// ```
19#[derive(Debug, Default)]
20pub struct RuleCV07;
21
22impl Rule for RuleCV07 {
23    fn code(&self) -> &'static str {
24        "CV07"
25    }
26    fn name(&self) -> &'static str {
27        "convention.prefer_coalesce"
28    }
29    fn description(&self) -> &'static str {
30        "Prefer COALESCE over CASE with IS NULL pattern."
31    }
32    fn explanation(&self) -> &'static str {
33        "A CASE expression like 'CASE WHEN x IS NULL THEN y ELSE x END' can be \
34         simplified to 'COALESCE(x, y)'. COALESCE is more concise and clearly \
35         expresses the intent of providing a fallback value for NULL."
36    }
37    fn groups(&self) -> &[RuleGroup] {
38        &[RuleGroup::Convention]
39    }
40    fn is_fixable(&self) -> bool {
41        false
42    }
43
44    fn crawl_type(&self) -> CrawlType {
45        CrawlType::Segment(vec![SegmentType::CaseExpression])
46    }
47
48    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
49        let children = ctx.segment.children();
50
51        // We're looking for the pattern:
52        //   CASE WHEN <expr> IS NULL THEN <val> ELSE <expr> END
53        //
54        // Structure: CaseExpression children typically include:
55        //   Keyword(CASE), WhenClause, ElseClause, Keyword(END), plus trivia
56
57        let non_trivia: Vec<_> = children
58            .iter()
59            .filter(|c| !c.segment_type().is_trivia())
60            .collect();
61
62        // Expect: CASE, one WhenClause, one ElseClause, END
63        // (at minimum 4 non-trivia children)
64        if non_trivia.len() < 4 {
65            return vec![];
66        }
67
68        // Count WHEN clauses — must be exactly one
69        let when_clauses: Vec<_> = non_trivia
70            .iter()
71            .filter(|c| c.segment_type() == SegmentType::WhenClause)
72            .collect();
73
74        if when_clauses.len() != 1 {
75            return vec![];
76        }
77
78        // Must have an ELSE clause
79        let else_clauses: Vec<_> = non_trivia
80            .iter()
81            .filter(|c| c.segment_type() == SegmentType::ElseClause)
82            .collect();
83
84        if else_clauses.len() != 1 {
85            return vec![];
86        }
87
88        let when_clause = when_clauses[0];
89        let else_clause = else_clauses[0];
90
91        // Check if the WHEN clause contains an IS NULL pattern
92        // WhenClause children: WHEN, <condition>, THEN, <result>
93        let when_children: Vec<_> = when_clause
94            .children()
95            .iter()
96            .filter(|c| !c.segment_type().is_trivia())
97            .collect();
98
99        // Look for IsNullExpression in the WHEN condition
100        let has_is_null = when_children
101            .iter()
102            .any(|c| c.segment_type() == SegmentType::IsNullExpression);
103
104        if !has_is_null {
105            return vec![];
106        }
107
108        // Extract the column being tested for NULL
109        let is_null_expr = when_children
110            .iter()
111            .find(|c| c.segment_type() == SegmentType::IsNullExpression);
112
113        let Some(is_null_expr) = is_null_expr else {
114            return vec![];
115        };
116
117        let tested_col = get_is_null_subject(is_null_expr);
118
119        // Extract the ELSE expression
120        let else_expr = get_else_expression(else_clause);
121
122        // If the column tested for NULL is the same as the ELSE expression,
123        // this is a COALESCE pattern
124        if let (Some(tested), Some(else_val)) = (tested_col, else_expr) {
125            if tested.eq_ignore_ascii_case(&else_val) {
126                return vec![LintViolation::new(
127                    self.code(),
128                    "Use COALESCE instead of CASE WHEN IS NULL pattern.",
129                    ctx.segment.span(),
130                )];
131            }
132        }
133
134        vec![]
135    }
136}
137
138/// Extract the subject of an IS NULL expression (the part before IS NULL).
139fn get_is_null_subject(segment: &Segment) -> Option<String> {
140    let children = segment.children();
141    let non_trivia: Vec<_> = children
142        .iter()
143        .filter(|c| !c.segment_type().is_trivia())
144        .collect();
145
146    // Pattern: <expr> IS NULL
147    // First non-trivia child should be the tested expression
148    non_trivia.first().map(|s| s.raw().trim().to_string())
149}
150
151/// Extract the expression from an ELSE clause (skip ELSE keyword).
152fn get_else_expression(segment: &Segment) -> Option<String> {
153    let children = segment.children();
154    let non_trivia: Vec<_> = children
155        .iter()
156        .filter(|c| !c.segment_type().is_trivia())
157        .collect();
158
159    // First non-trivia is ELSE keyword, rest is the expression
160    if non_trivia.len() >= 2 {
161        let expr_parts: String = non_trivia[1..]
162            .iter()
163            .map(|s| s.raw())
164            .collect::<Vec<_>>()
165            .join("");
166        Some(expr_parts.trim().to_string())
167    } else {
168        None
169    }
170}