Skip to content

Commit ac8469e

Browse files
hellozzmPawansingh3889
authored andcommitted
feat: add W014 case-without-else rule
Warn on CASE expressions that lack an ELSE branch.\nUnmatched rows silently return NULL which is often unintended.\n\nCloses #4
1 parent 73497e8 commit ac8469e

4 files changed

Lines changed: 66 additions & 4 deletions

File tree

sql_guard/rules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
UpdateWithoutWhere,
1515
)
1616
from sql_guard.rules.warnings import (
17+
CaseWithoutElse,
1718
CommentedOutCode,
1819
CountDistinctUnbounded,
1920
FunctionOnIndexedColumn,
@@ -79,6 +80,7 @@
7980
TruncateTable(),
8081
CountDistinctUnbounded(),
8182
ScalarUdfInWhere(),
83+
CaseWithoutElse(),
8284
# Structural (S001-S003)
8385
ImplicitCrossJoin(),
8486
DeeplyNestedSubquery(),

sql_guard/rules/warnings.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,38 @@ def check_statement(self, statement: str, start_line: int, file: str) -> Finding
484484
return None
485485

486486

487+
class CaseWithoutElse(Rule):
488+
"""W014: CASE expression without ELSE returns NULL for unmatched rows.
489+
490+
``CASE WHEN ... THEN ... END`` without an ``ELSE`` branch returns
491+
``NULL`` for any row that doesn't match a ``WHEN`` condition.
492+
Often the author assumes the conditions are exhaustive when they
493+
aren't, or downstream code can't handle NULLs.
494+
"""
495+
496+
id = "W014"
497+
name = "case-without-else"
498+
severity = "warning"
499+
description = "CASE without ELSE returns NULL for unmatched rows"
500+
multiline = True
501+
502+
_case = Rule._compile(r"\bCASE\b")
503+
_end = Rule._compile(r"\bEND\b")
504+
_else = Rule._compile(r"\bELSE\b")
505+
506+
def check_statement(self, statement: str, start_line: int, file: str) -> Finding | None:
507+
if self._case.search(statement) and self._end.search(statement) and not self._else.search(statement):
508+
return Finding(
509+
rule_id=self.id,
510+
severity=self.severity,
511+
file=file,
512+
line=start_line,
513+
message="CASE without ELSE -- unmatched rows return NULL",
514+
suggestion="Add an explicit ELSE clause, even if it's ELSE NULL for clarity",
515+
)
516+
return None
517+
518+
487519
class WindowMissingPartition(Rule):
488520
"""W013: OVER() without PARTITION BY can yield unpredictable results."""
489521

tests/fixtures/warnings.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,11 @@ WHERE dbo.fn_IsHighValue(total) = 1;
4444
SELECT *
4545
FROM orders o
4646
JOIN customers c ON UPPER(o.email) = UPPER(c.email);
47+
48+
-- W014: CASE without ELSE
49+
SELECT
50+
CASE
51+
WHEN status = 'paid' THEN 1
52+
WHEN status = 'pending' THEN 0
53+
END AS paid_flag
54+
FROM orders;

tests/test_rules.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@
1818

1919
class TestRuleRegistry:
2020
def test_all_rules_loaded(self) -> None:
21-
assert len(ALL_RULES) == 36
21+
assert len(ALL_RULES) == 37
2222

2323
def test_10_errors(self) -> None:
2424
# 8 E-series + 2 T-series (T002 xp-cmdshell, T004 deprecated-outer-join).
2525
errors = [r for r in ALL_RULES if r.severity == "error"]
2626
assert len(errors) == 10
2727

28-
def test_26_warnings(self) -> None:
29-
# 20 W-series + 3 S-series + 3 T-series (T001 with-nolock,
28+
def test_27_warnings(self) -> None:
29+
# 21 W-series + 3 S-series + 3 T-series (T001 with-nolock,
3030
# T003 cursor-declaration, T005 create-index-without-online).
3131
warnings = [r for r in ALL_RULES if r.severity == "warning"]
32-
assert len(warnings) == 26
32+
assert len(warnings) == 27
3333

3434
def test_unique_ids(self) -> None:
3535
ids = [r.id for r in ALL_RULES]
@@ -193,6 +193,26 @@ def test_w016_not_in_value_list_ok(self, tmp_path) -> None:
193193
w016 = [f for f in result.findings if f.rule_id == "W016"]
194194
assert not w016
195195

196+
def test_w014_case_without_else(self) -> None:
197+
findings = check([str(FIXTURES / "warnings.sql")])
198+
w014 = [f for f in findings.findings if f.rule_id == "W014"]
199+
assert len(w014) >= 1
200+
assert "CASE" in w014[0].message
201+
202+
def test_w014_case_with_else_ok(self, tmp_path) -> None:
203+
sql = tmp_path / "case_with_else.sql"
204+
sql.write_text(
205+
"SELECT CASE\n"
206+
" WHEN status = 'paid' THEN 1\n"
207+
" WHEN status = 'pending' THEN 0\n"
208+
" ELSE NULL\n"
209+
"END AS paid_flag\n"
210+
"FROM orders;\n"
211+
)
212+
result = check([str(sql)])
213+
w014 = [f for f in result.findings if f.rule_id == "W014"]
214+
assert not w014
215+
196216

197217
# ---------------------------------------------------------------------------
198218
# Clean file

0 commit comments

Comments
 (0)