Skip to main content

flowscope_core/linter/rules/
cv_005.rs

1//! LINT_CV_005: Prefer IS [NOT] NULL over =/<> NULL.
2//!
3//! Comparisons like `col = NULL` or `col <> NULL` are not valid null checks in SQL.
4//! Use `IS NULL` / `IS NOT NULL` instead.
5
6use crate::linter::rule::{LintContext, LintRule};
7use crate::linter::visit;
8use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit};
9use sqlparser::ast::{Spanned, *};
10
11pub struct NullComparison;
12
13impl LintRule for NullComparison {
14    fn code(&self) -> &'static str {
15        issue_codes::LINT_CV_005
16    }
17
18    fn name(&self) -> &'static str {
19        "Null comparison style"
20    }
21
22    fn description(&self) -> &'static str {
23        "Comparisons with NULL should use \"IS\" or \"IS NOT\"."
24    }
25
26    fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
27        let mut issues = Vec::new();
28        visit::visit_expressions(stmt, &mut |expr| {
29            let Expr::BinaryOp { left, op, right } = expr else {
30                return;
31            };
32
33            if !is_null_expr(left) && !is_null_expr(right) {
34                return;
35            }
36
37            let null_case = detect_null_case(ctx, expr);
38            let (message, replacement_target, replacement_suffix) = match op {
39                BinaryOperator::Eq => (
40                    Some("Use IS NULL instead of = NULL."),
41                    non_null_operand(left, right),
42                    if null_case == KeywordCase::Lower {
43                        " is null"
44                    } else {
45                        " IS NULL"
46                    },
47                ),
48                BinaryOperator::NotEq => (
49                    Some("Use IS NOT NULL instead of <> NULL or != NULL."),
50                    non_null_operand(left, right),
51                    if null_case == KeywordCase::Lower {
52                        " is not null"
53                    } else {
54                        " IS NOT NULL"
55                    },
56                ),
57                _ => (None, None, ""),
58            };
59
60            if let Some(message) = message {
61                let mut issue = Issue::info(issue_codes::LINT_CV_005, message)
62                    .with_statement(ctx.statement_index);
63                if let (Some(target_expr), Some((start, end))) =
64                    (replacement_target, expr_statement_offsets(ctx, expr))
65                {
66                    let span = ctx.span_from_statement_offset(start, end);
67                    let replacement = format!("{target_expr}{replacement_suffix}");
68                    issue = issue.with_span(span).with_autofix_edits(
69                        IssueAutofixApplicability::Safe,
70                        vec![IssuePatchEdit::new(span, replacement)],
71                    );
72                }
73                issues.push(issue);
74            }
75        });
76        issues
77    }
78}
79
80#[derive(Clone, Copy, PartialEq, Eq)]
81enum KeywordCase {
82    Upper,
83    Lower,
84}
85
86/// Detect whether the `NULL` keyword in the original SQL is uppercase or lowercase.
87fn detect_null_case(ctx: &LintContext, expr: &Expr) -> KeywordCase {
88    if let Some((start, end)) = expr_statement_offsets(ctx, expr) {
89        let fragment = &ctx.statement_sql()[start..end];
90        // Look for the literal "null" (case-insensitive) in the expression text.
91        if let Some(pos) = fragment.to_ascii_lowercase().rfind("null") {
92            let null_text = &fragment[pos..pos + 4];
93            if null_text == "null" {
94                return KeywordCase::Lower;
95            }
96        }
97    }
98    KeywordCase::Upper
99}
100
101fn is_null_expr(expr: &Expr) -> bool {
102    matches!(
103        expr,
104        Expr::Value(ValueWithSpan {
105            value: Value::Null,
106            ..
107        })
108    )
109}
110
111fn non_null_operand<'a>(left: &'a Expr, right: &'a Expr) -> Option<&'a Expr> {
112    if is_null_expr(left) && !is_null_expr(right) {
113        Some(right)
114    } else if is_null_expr(right) && !is_null_expr(left) {
115        Some(left)
116    } else if is_null_expr(left) && is_null_expr(right) {
117        Some(right)
118    } else {
119        None
120    }
121}
122
123fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> {
124    if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) {
125        return Some((start, end));
126    }
127
128    let (start, end) = expr_span_offsets(ctx.sql, expr)?;
129    if start < ctx.statement_range.start || end > ctx.statement_range.end {
130        return None;
131    }
132
133    Some((
134        start - ctx.statement_range.start,
135        end - ctx.statement_range.start,
136    ))
137}
138
139fn expr_span_offsets(sql: &str, expr: &Expr) -> Option<(usize, usize)> {
140    let span = expr.span();
141    if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
142    {
143        return None;
144    }
145
146    let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
147    let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
148    if end < start {
149        return None;
150    }
151
152    Some((start, end))
153}
154
155fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
156    if line == 0 || column == 0 {
157        return None;
158    }
159
160    let mut current_line = 1usize;
161    let mut line_start = 0usize;
162
163    for (idx, ch) in sql.char_indices() {
164        if current_line == line {
165            break;
166        }
167        if ch == '\n' {
168            current_line += 1;
169            line_start = idx + ch.len_utf8();
170        }
171    }
172
173    if current_line != line {
174        return None;
175    }
176
177    let mut current_column = 1usize;
178    for (rel_idx, ch) in sql[line_start..].char_indices() {
179        if current_column == column {
180            return Some(line_start + rel_idx);
181        }
182        if ch == '\n' {
183            return None;
184        }
185        current_column += 1;
186    }
187
188    if current_column == column {
189        return Some(sql.len());
190    }
191
192    None
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::parser::parse_sql;
199    use crate::types::IssueAutofixApplicability;
200
201    fn check_sql(sql: &str) -> Vec<Issue> {
202        let stmts = parse_sql(sql).unwrap();
203        let rule = NullComparison;
204        let ctx = LintContext {
205            sql,
206            statement_range: 0..sql.len(),
207            statement_index: 0,
208        };
209        let mut issues = Vec::new();
210        for stmt in &stmts {
211            issues.extend(rule.check(stmt, &ctx));
212        }
213        issues
214    }
215
216    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
217        let autofix = issue.autofix.as_ref()?;
218        let mut edits = autofix.edits.clone();
219        edits.sort_by(|left, right| right.span.start.cmp(&left.span.start));
220
221        let mut out = sql.to_string();
222        for edit in edits {
223            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
224        }
225        Some(out)
226    }
227
228    #[test]
229    fn test_eq_null_detected() {
230        let issues = check_sql("SELECT * FROM t WHERE a = NULL");
231        assert_eq!(issues.len(), 1);
232        assert_eq!(issues[0].code, "LINT_CV_005");
233    }
234
235    #[test]
236    fn test_not_eq_null_detected() {
237        let issues = check_sql("SELECT * FROM t WHERE a <> NULL");
238        assert_eq!(issues.len(), 1);
239        assert_eq!(issues[0].code, "LINT_CV_005");
240    }
241
242    #[test]
243    fn test_null_left_side_detected() {
244        let issues = check_sql("SELECT * FROM t WHERE NULL = a");
245        assert_eq!(issues.len(), 1);
246    }
247
248    #[test]
249    fn test_is_null_ok() {
250        let issues = check_sql("SELECT * FROM t WHERE a IS NULL");
251        assert!(issues.is_empty());
252    }
253
254    #[test]
255    fn test_is_not_null_ok() {
256        let issues = check_sql("SELECT * FROM t WHERE a IS NOT NULL");
257        assert!(issues.is_empty());
258    }
259
260    #[test]
261    fn test_eq_null_emits_safe_autofix_patch() {
262        let sql = "SELECT * FROM t WHERE a = NULL";
263        let issues = check_sql(sql);
264        let issue = &issues[0];
265        let autofix = issue.autofix.as_ref().expect("autofix metadata");
266
267        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
268        assert_eq!(autofix.edits.len(), 1);
269
270        let expected_start = sql.find("a = NULL").expect("comparison exists");
271        let expected_end = expected_start + "a = NULL".len();
272        assert_eq!(autofix.edits[0].span.start, expected_start);
273        assert_eq!(autofix.edits[0].span.end, expected_end);
274        assert_eq!(autofix.edits[0].replacement, "a IS NULL");
275
276        let fixed = apply_issue_autofix(sql, issue).expect("apply autofix");
277        assert_eq!(fixed, "SELECT * FROM t WHERE a IS NULL");
278    }
279
280    #[test]
281    fn test_not_eq_lowercase_null_emits_lowercase_autofix() {
282        let sql = "SELECT a FROM foo WHERE a <> null";
283        let issues = check_sql(sql);
284        assert_eq!(issues.len(), 1);
285        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
286        assert_eq!(fixed, "SELECT a FROM foo WHERE a is not null");
287    }
288
289    #[test]
290    fn test_null_left_not_eq_emits_safe_autofix_patch() {
291        let sql = "SELECT * FROM t WHERE NULL <> a";
292        let issues = check_sql(sql);
293        let issue = &issues[0];
294        let autofix = issue.autofix.as_ref().expect("autofix metadata");
295
296        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
297        assert_eq!(autofix.edits.len(), 1);
298
299        let expected_start = sql.find("NULL <> a").expect("comparison exists");
300        let expected_end = expected_start + "NULL <> a".len();
301        assert_eq!(autofix.edits[0].span.start, expected_start);
302        assert_eq!(autofix.edits[0].span.end, expected_end);
303        assert_eq!(autofix.edits[0].replacement, "a IS NOT NULL");
304
305        let fixed = apply_issue_autofix(sql, issue).expect("apply autofix");
306        assert_eq!(fixed, "SELECT * FROM t WHERE a IS NOT NULL");
307    }
308}