Skip to content

Commit ea7fa59

Browse files
docs: add W014 to CHANGELOG and README, bump key numbers to 41/26
1 parent ac8469e commit ea7fa59

4 files changed

Lines changed: 72 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ a deprecation window (see `GOVERNANCE.md` § Scope discipline).
1212

1313
### Added
1414

15+
- **W014 `case-without-else`** - warns when a `CASE ... END` block has
16+
no `ELSE` branch, so unmatched rows return `NULL`. Walks the
17+
statement token-by-token tracking nesting, so an outer `CASE` with no
18+
`ELSE` still fires even when an inner `CASE` does have one. Fires per
19+
unmatched block. Suggests adding `ELSE NULL` for explicitness.
20+
Contributed by [@hellozzm](https://github.com/hellozzm)
21+
([#32](https://github.com/Pawansingh3889/sql-guard/pull/32)).
1522
- **W015 `join-function-on-column`** - warns when a function wraps a
1623
column inside a `JOIN ... ON` predicate, the JOIN-side companion to W003.
1724
`JOIN customers c ON UPPER(o.email) = UPPER(c.email)` defeats every

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ One bad SQL query can delete production data, expose customer records, or bring
2424

2525
| | |
2626
|---|---|
27-
| Rules | 41 (10 errors, 26 warnings, 5 Python-source) |
27+
| Rules | 42 (10 errors, 27 warnings, 5 Python-source) |
2828
| Tests | 152 |
2929
| Coverage | 86% |
3030
| Scan speed | 0.08s across 200 files |
@@ -43,7 +43,7 @@ print(result.summary()) # "1 error, 0 warnings in 1 statement"
4343

4444
---
4545

46-
Fast, rule-based SQL linter. 41 rules (36 SQL + 5 Python), including SQL Server-focused rules for T-SQL shops. Inline disable, project config, git-changed-only mode, and SARIF output for GitHub Code Scanning. 500+ monthly downloads on PyPI.
46+
Fast, rule-based SQL linter. 42 rules (37 SQL + 5 Python), including SQL Server-focused rules for T-SQL shops. Inline disable, project config, git-changed-only mode, and SARIF output for GitHub Code Scanning. 500+ monthly downloads on PyPI.
4747

4848
Catches dangerous SQL before it reaches production -- DELETE without WHERE, UPDATE without WHERE, SQL injection patterns, SELECT *, and 20 more. Runs as a **CLI tool**, **pre-commit hook**, and **GitHub Action**.
4949

@@ -227,6 +227,7 @@ sql-sop list-rules # show every registered rule
227227
| W009 | `missing-semicolon` | Statement not terminated with `;` |
228228
| W010 | `commented-out-code` | `-- SELECT * FROM old_table` -- use version control |
229229
| W013 | `window-missing-partition` | `OVER ()` -- unpredictable results and unclear intent |
230+
| W014 | `case-without-else` | `CASE WHEN ... THEN ... END` -- unmatched rows return NULL |
230231
| W015 | `join-function-on-column` | `JOIN customers c ON UPPER(o.email) = UPPER(c.email)` -- kills index seek |
231232
| W016 | `not-in-with-subquery` | `WHERE id NOT IN (SELECT ...)` -- silently returns 0 rows on NULL
232233
| W017 | `leading-wildcard-like` | `WHERE name LIKE '%smith'` -- non-SARGable, full scan |

sql_guard/rules/warnings.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,13 @@ class CaseWithoutElse(Rule):
491491
``NULL`` for any row that doesn't match a ``WHEN`` condition.
492492
Often the author assumes the conditions are exhaustive when they
493493
aren't, or downstream code can't handle NULLs.
494+
495+
Walks ``CASE`` / ``ELSE`` / ``END`` tokens with a depth-aware stack
496+
so a nested-but-complete ``CASE`` doesn't mask an outer one that
497+
lacks ``ELSE``. Each ``CASE`` block is judged on its own ``ELSE``
498+
count. Standalone ``END`` tokens (for example ``BEGIN ... END``
499+
blocks in T-SQL) are ignored when no matching ``CASE`` is on the
500+
stack.
494501
"""
495502

496503
id = "W014"
@@ -499,20 +506,36 @@ class CaseWithoutElse(Rule):
499506
description = "CASE without ELSE returns NULL for unmatched rows"
500507
multiline = True
501508

502-
_case = Rule._compile(r"\bCASE\b")
503-
_end = Rule._compile(r"\bEND\b")
504-
_else = Rule._compile(r"\bELSE\b")
509+
_case_keyword = Rule._compile(r"\b(CASE|END|ELSE)\b")
505510

506511
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-
)
512+
# Walk CASE/ELSE/END tokens with a depth-aware stack. Each CASE
513+
# pushes an entry; ELSE marks the current entry; END pops and
514+
# decides. Nested CASEs are judged independently, so an outer
515+
# CASE with no ELSE still fires even if an inner one has ELSE.
516+
stack: list[bool] = [] # one entry per open CASE; True if ELSE seen
517+
for match in self._case_keyword.finditer(statement):
518+
word = match.group(1).upper()
519+
if word == "CASE":
520+
stack.append(False)
521+
elif word == "ELSE":
522+
if stack:
523+
stack[-1] = True
524+
elif word == "END":
525+
if not stack:
526+
# END with no matching CASE -- e.g. a T-SQL BEGIN/END
527+
# block. Skip rather than false-fire.
528+
continue
529+
had_else = stack.pop()
530+
if not had_else:
531+
return Finding(
532+
rule_id=self.id,
533+
severity=self.severity,
534+
file=file,
535+
line=start_line,
536+
message="CASE without ELSE -- unmatched rows return NULL",
537+
suggestion="Add an explicit ELSE clause, even if it's ELSE NULL for clarity",
538+
)
516539
return None
517540

518541

tests/test_rules.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,33 @@ def test_w014_case_with_else_ok(self, tmp_path) -> None:
213213
w014 = [f for f in result.findings if f.rule_id == "W014"]
214214
assert not w014
215215

216+
def test_w014_outer_case_without_else_fires_when_inner_has_else(
217+
self, tmp_path
218+
) -> None:
219+
# Issue #4 specifically called out the nested case: an outer
220+
# CASE with no ELSE must still fire even when an inner CASE
221+
# does have one.
222+
from sql_guard.rules.warnings import CaseWithoutElse
223+
224+
rule = CaseWithoutElse()
225+
nested = (
226+
"SELECT CASE\n"
227+
" WHEN x THEN CASE WHEN y THEN 1 ELSE 2 END\n"
228+
" WHEN z THEN 3\n"
229+
"END FROM t;"
230+
)
231+
finding = rule.check_statement(nested, 1, "test.sql")
232+
assert finding is not None
233+
assert finding.rule_id == "W014"
234+
235+
def test_w014_does_not_fire_on_begin_end_block(self) -> None:
236+
# T-SQL BEGIN/END blocks should not trip the rule on their own.
237+
from sql_guard.rules.warnings import CaseWithoutElse
238+
239+
rule = CaseWithoutElse()
240+
proc = "BEGIN\n SELECT 1;\nEND;"
241+
assert rule.check_statement(proc, 1, "test.sql") is None
242+
216243

217244
# ---------------------------------------------------------------------------
218245
# Clean file

0 commit comments

Comments
 (0)