flowscope_core/linter/rules/
cv_005.rs1use 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
86fn 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 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}