Skip to content

Commit c54c6f7

Browse files
committed
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 db6021f commit c54c6f7

4 files changed

Lines changed: 64 additions & 2 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,
@@ -75,6 +76,7 @@
7576
OrAcrossColumns(),
7677
TruncateTable(),
7778
CountDistinctUnbounded(),
79+
CaseWithoutElse(),
7880
# Structural (S001-S003)
7981
ImplicitCrossJoin(),
8082
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
@@ -34,3 +34,11 @@ FROM events;
3434
SELECT *
3535
FROM customers
3636
WHERE id NOT IN (SELECT customer_id FROM orders);
37+
38+
-- W014: CASE without ELSE
39+
SELECT
40+
CASE
41+
WHEN status = 'paid' THEN 1
42+
WHEN status = 'pending' THEN 0
43+
END AS paid_flag
44+
FROM orders;

tests/test_rules.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

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

2323
def test_10_errors(self) -> None:
2424
# 8 E-series + 2 T-series (T002 xp-cmdshell, T004 deprecated-outer-join).
@@ -29,7 +29,7 @@ def test_24_warnings(self) -> None:
2929
# 18 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) == 24
32+
assert len(warnings) == 25
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)