Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ a deprecation window (see `GOVERNANCE.md` § Scope discipline).
so a clean JOIN with a dirty WHERE leaves W015 quiet and lets W003 own
that case. Contributed by [@mvanhorn](https://github.com/mvanhorn)
([#33](https://github.com/Pawansingh3889/sql-guard/pull/33)).
- **W022 `cross-join-explicit`** - warns on explicit `CROSS JOIN`. Cross
joins multiply every row in the left table with every row in the right
table (a Cartesian product). Almost always a mistake unless the author
intends a calendar-grid or lookup-table generation pattern. The pattern
strips trailing line comments before matching so `-- avoid CROSS JOIN`
in a trailing comment does not trip the rule. Suppress with
`-- sql-guard: disable=W022` on the same line. Contributed by
[@vibeyclaw](https://github.com/vibeyclaw)
([#31](https://github.com/Pawansingh3889/sql-guard/pull/31)).
- W023 `scalar-udf-in-where`: warns on `<schema>.<name>(...)` calls in
`WHERE`/`HAVING`/`ON` clauses, the canonical T-SQL scalar-UDF
anti-pattern. Built-ins (no schema prefix) are unaffected.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ One bad SQL query can delete production data, expose customer records, or bring

| | |
|---|---|
| Rules | 42 (10 errors, 27 warnings, 5 Python-source) |
| Rules | 43 (10 errors, 28 warnings, 5 Python-source) |
| Tests | 152 |
| Coverage | 86% |
| Scan speed | 0.08s across 200 files |
Expand All @@ -43,7 +43,7 @@ print(result.summary()) # "1 error, 0 warnings in 1 statement"

---

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.
Fast, rule-based SQL linter. 43 rules (38 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.

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**.

Expand Down Expand Up @@ -233,6 +233,7 @@ sql-sop list-rules # show every registered rule
| W017 | `leading-wildcard-like` | `WHERE name LIKE '%smith'` -- non-SARGable, full scan |
| W018 | `or-across-columns` | `WHERE a = 1 OR b = 2` -- defeats single-column indexes |
| W020 | `truncate-table` | `TRUNCATE TABLE staging;` -- bypasses triggers, resets identity |
| W022 | `cross-join-explicit` | `FROM products CROSS JOIN regions` -- Cartesian product, confirm intent |
| W023 | `scalar-udf-in-where` | `WHERE dbo.fn_X(col) = 1` -- row-by-row predicate evaluation |


Expand Down
4 changes: 3 additions & 1 deletion sql_guard/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
CaseWithoutElse,
CommentedOutCode,
CountDistinctUnbounded,
CrossJoinExplicit,
FunctionOnIndexedColumn,
GroupByOrdinal,
HardcodedValues,
Expand All @@ -34,7 +35,7 @@
SubqueryCouldBeJoin,
TruncateTable,
UnionWithoutAll,
WindowMissingPartition
WindowMissingPartition,
)
from sql_guard.rules.structural import (
DeeplyNestedSubquery,
Expand Down Expand Up @@ -81,6 +82,7 @@
CountDistinctUnbounded(),
ScalarUdfInWhere(),
CaseWithoutElse(),
CrossJoinExplicit(),
# Structural (S001-S003)
ImplicitCrossJoin(),
DeeplyNestedSubquery(),
Expand Down
39 changes: 39 additions & 0 deletions sql_guard/rules/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,3 +643,42 @@ def check_line(self, line: str, line_number: int, file: str) -> Finding | None:
suggestion="Materialize the function into a stored column on both sides: JOIN customers c ON o.email_lower = c.email_lower",
)
return None


class CrossJoinExplicit(Rule):
"""W022: Explicit CROSS JOIN is rarely intentional.

Cross joins multiply every row in the left table with every row in the
right table (a Cartesian product). They are almost always a mistake
unless the developer explicitly intends a calendar-grid or lookup-table
generation pattern. Warn so the author confirms intent.

Suppress with an inline ``-- noqa: W022`` comment on the same line, or
use the project-wide ``-- sql-guard: disable=W022`` directive.
"""

id = "W022"
name = "cross-join-explicit"
severity = "warning"
description = "Explicit CROSS JOIN produces a Cartesian product; confirm this is intentional"
multiline = False

_cross_join = Rule._compile(r"\bCROSS\s+JOIN\b")
_line_comment = Rule._compile(r"--.*$")

def check_line(self, line: str, line_number: int, file: str) -> Finding | None:
# Strip line comments before matching so 'CROSS JOIN' inside a
# trailing comment does not trip the rule. String-literal masking
# is left to the existing project-wide approach (rules accept the
# same edge cases as W003 / W004 for consistency).
body = self._line_comment.sub("", line)
if self._cross_join.search(body):
return Finding(
rule_id=self.id,
severity=self.severity,
file=file,
line=line_number,
message="Explicit CROSS JOIN -- Cartesian product, confirm this is intentional",
suggestion="If intentional, suppress with '-- sql-guard: disable=W022' on the same line",
)
return None
60 changes: 59 additions & 1 deletion tests/test_new_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from sql_guard.rules.tsql import CreateIndexWithoutOnline
from sql_guard.rules.warnings import (
CountDistinctUnbounded,
CrossJoinExplicit,
LeadingWildcardLike,
OrAcrossColumns,
ScalarUdfInWhere,
Expand Down Expand Up @@ -277,7 +278,6 @@ def test_w023_passes_table_column_reference():
assert _stmt(rule, "SELECT id FROM t WHERE x.y = 1;") is None



# W015 join-function-on-column ------------------------------------------------


Expand Down Expand Up @@ -330,3 +330,61 @@ def test_w015_does_not_flag_clean_join_with_dirty_where():
"WHERE UPPER(o.email) = 'A@B.COM'"
)
assert _line(rule, sql) is None


# W022 cross-join-explicit ----------------------------------------------------


def test_w022_flags_explicit_cross_join():
rule = CrossJoinExplicit()
finding = _line(rule, "SELECT * FROM products p CROSS JOIN regions r;")
assert finding is not None
assert finding.rule_id == "W022"
assert finding.severity == "warning"


def test_w022_flags_cross_join_case_insensitive():
rule = CrossJoinExplicit()
finding = _line(rule, "select * from products cross join regions;")
assert finding is not None
assert finding.rule_id == "W022"


def test_w022_passes_regular_inner_join():
rule = CrossJoinExplicit()
assert (
_line(rule, "SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;")
is None
)


def test_w022_passes_left_join():
rule = CrossJoinExplicit()
assert (
_line(
rule,
"SELECT * FROM orders o LEFT JOIN customers c ON o.customer_id = c.id;",
)
is None
)


def test_w022_flags_cross_join_with_subquery():
rule = CrossJoinExplicit()
finding = _line(
rule, "SELECT * FROM calendar_dates CROSS JOIN (SELECT 1 AS n UNION ALL SELECT 2);"
)
assert finding is not None
assert finding.rule_id == "W022"


def test_w022_does_not_flag_cross_join_inside_trailing_comment():
# 'CROSS JOIN' mentioned in a trailing comment must not trip the rule.
rule = CrossJoinExplicit()
assert (
_line(
rule,
"SELECT * FROM orders o JOIN customers c ON o.id = c.id; -- avoid CROSS JOIN here",
)
is None
)
8 changes: 4 additions & 4 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@

class TestRuleRegistry:
def test_all_rules_loaded(self) -> None:
assert len(ALL_RULES) == 37
assert len(ALL_RULES) == 38

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

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

def test_unique_ids(self) -> None:
ids = [r.id for r in ALL_RULES]
Expand Down
Loading