Skip to content

Commit 6240050

Browse files
authored
feat(rules): add W013 window-without-partition rule (#21)
Warns on window functions using OVER () without PARTITION BY, flagging non-deterministic ordering and full-result-set scans. Contributed by @Prabhu-1409. Closes #9.
1 parent 0be10dd commit 6240050

6 files changed

Lines changed: 71 additions & 5 deletions

File tree

CHANGELOG.md

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

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- **W013 `window-without-partition`** - warns on window functions
16+
using OVER () without PARTITION BY, flagging non-deterministic
17+
ordering and full-result-set scans.
18+
1319
## [0.6.1] - 2026-04-26
1420

1521
### Added
@@ -116,6 +122,8 @@ a deprecation window (see `GOVERNANCE.md` § Scope discipline).
116122
- `test_duration_tracked` no longer fails on fast hardware where
117123
`time.perf_counter` resolution is coarser than the scan duration.
118124
Assertion relaxed from `> 0` to `>= 0` with an explicit float type check.
125+
- Added W014: warn on OVER() without ORDER BY / PARTITION BY to flag non-deterministic window
126+
functions.
119127

120128
## [0.4.1] - 2026-04-19
121129

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,13 @@ sql-sop list-rules # show every registered rule
226226
| W008 | `mixed-case-keywords` | `select ... FROM` -- inconsistent casing |
227227
| W009 | `missing-semicolon` | Statement not terminated with `;` |
228228
| W010 | `commented-out-code` | `-- SELECT * FROM old_table` -- use version control |
229-
| W016 | `not-in-with-subquery` | `WHERE id NOT IN (SELECT ...)` -- silently returns 0 rows on NULL |
229+
| W013 | `window-missing-partition` | `OVER ()` -- unpredictable results and unclear intent |
230+
| W016 | `not-in-with-subquery` | `WHERE id NOT IN (SELECT ...)` -- silently returns 0 rows on NULL
230231
| W017 | `leading-wildcard-like` | `WHERE name LIKE '%smith'` -- non-SARGable, full scan |
231232
| W018 | `or-across-columns` | `WHERE a = 1 OR b = 2` -- defeats single-column indexes |
232233
| W020 | `truncate-table` | `TRUNCATE TABLE staging;` -- bypasses triggers, resets identity |
233234

235+
234236
### T-SQL (v0.5.0+)
235237

236238
Rules targeting SQL Server anti-patterns common in legacy stored procs

sql_guard/rules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
SubqueryCouldBeJoin,
3232
TruncateTable,
3333
UnionWithoutAll,
34+
WindowMissingPartition
3435
)
3536
from sql_guard.rules.structural import (
3637
DeeplyNestedSubquery,
@@ -67,6 +68,7 @@
6768
MissingSemicolon(),
6869
CommentedOutCode(),
6970
UnionWithoutAll(),
71+
WindowMissingPartition(),
7072
GroupByOrdinal(),
7173
NotInWithSubquery(),
7274
LeadingWildcardLike(),

sql_guard/rules/warnings.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,36 @@ def check_statement(self, statement: str, start_line: int, file: str) -> Finding
482482
suggestion="Add a WHERE/LIMIT to restrict scope, or pre-aggregate with GROUP BY",
483483
)
484484
return None
485+
486+
487+
class WindowMissingPartition(Rule):
488+
"""W013: OVER() without PARTITION BY can yield unpredictable results."""
489+
490+
id = "W013"
491+
name = "window-missing-partition"
492+
severity = "warning"
493+
description = "OVER() without PARTITION BY may lead to unpredictable results and unclear intent."
494+
multiline = True
495+
496+
_over_pattern = Rule._compile(r"\bOVER\s*\(")
497+
_partition_pattern = Rule._compile(r"PARTITION\s+BY")
498+
499+
def has_valid_over_clause(self, statement: str) -> bool:
500+
# If no OVER(...) → nothing to check
501+
if not self._over_pattern.search(statement):
502+
return True
503+
504+
# Valid only if PARTITION BY exists
505+
return bool(self._partition_pattern.search(statement))
506+
507+
def check_statement(self, statement: str, start_line: int, file: str) -> Finding | None:
508+
if not self.has_valid_over_clause(statement):
509+
return Finding(
510+
rule_id=self.id,
511+
severity=self.severity,
512+
file=file,
513+
line=start_line,
514+
message="Missing PARTITION BY in OVER clause",
515+
suggestion="Add PARTITION BY to define window groups clearly",
516+
)
517+
return None

tests/fixtures/warnings.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ SELECT region, status, COUNT(*)
2222
FROM orders
2323
GROUP BY 1, 2;
2424

25+
26+
-- W013: OVER without PARTITION BY
27+
SELECT
28+
user_id,
29+
ROW_NUMBER() OVER () AS rn
30+
FROM events;
31+
32+
2533
-- W016: NOT IN with subquery
2634
SELECT *
2735
FROM customers

tests/test_rules.py

Lines changed: 17 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) == 33
21+
assert len(ALL_RULES) == 34
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_23_warnings(self) -> None:
29-
# 17 W-series + 3 S-series + 3 T-series (T001 with-nolock,
28+
def test_24_warnings(self) -> None:
29+
# 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) == 23
32+
assert len(warnings) == 24
3333

3434
def test_unique_ids(self) -> None:
3535
ids = [r.id for r in ALL_RULES]
@@ -166,6 +166,19 @@ def test_w012_passes_on_digit_prefixed_column_name(self) -> None:
166166
statement = "SELECT 1st_quarter, COUNT(*) FROM sales GROUP BY 1st_quarter;"
167167
assert rule.check_statement(statement, 1, "test.sql") is None
168168

169+
def test_w013_window_missing_partition(self) -> None:
170+
findings = check([str(FIXTURES / "warnings.sql")])
171+
w013 = [f for f in findings.findings if f.rule_id == "W013"]
172+
assert len(w013) >= 1
173+
174+
def test_w013_passes_on_valid_over_clause(self) -> None:
175+
from sql_guard.rules.warnings import WindowMissingPartition
176+
177+
rule = WindowMissingPartition()
178+
statement = "SELECT ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY id) AS rn FROM users;"
179+
180+
assert rule.check_statement(statement, 1, "test.sql") is None
181+
169182
def test_w016_not_in_with_subquery(self) -> None:
170183
findings = check([str(FIXTURES / "warnings.sql")])
171184
w016 = [f for f in findings.findings if f.rule_id == "W016"]

0 commit comments

Comments
 (0)