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
2 changes: 1 addition & 1 deletion pymongosql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
if TYPE_CHECKING:
from .connection import Connection

__version__: str = "0.4.6"
__version__: str = "0.4.7"

# Globals https://www.python.org/dev/peps/pep-0249/#globals
apilevel: str = "2.0"
Expand Down
42 changes: 27 additions & 15 deletions pymongosql/sql/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def _is_comparison_context(self, ctx: Any) -> bool:
def _has_comparison_pattern(self, ctx: Any) -> bool:
"""Check if the expression text contains comparison patterns"""
try:
text = self.get_context_text(ctx)
text = self.get_context_text(ctx).upper()
# Extended pattern matching for SQL constructs
patterns = COMPARISON_OPERATORS + ["LIKE", "IN", "BETWEEN", "ISNULL", "ISNOTNULL"]
return any(op in text for op in patterns)
Expand All @@ -327,12 +327,14 @@ def _extract_field_name(self, ctx: Any) -> str:
"""Extract field name from comparison expression"""
try:
text = self.get_context_text(ctx)
text_upper = text.upper()

# Handle SQL constructs with keywords
sql_keywords = ["IN(", "LIKE", "BETWEEN", "ISNULL", "ISNOTNULL"]
for keyword in sql_keywords:
if keyword in text:
candidate = text.split(keyword, 1)[0].strip()
if keyword in text_upper:
idx = text_upper.index(keyword)
candidate = text[:idx].strip()
return self.normalize_field_path(candidate)

# Try operator-based splitting
Expand All @@ -359,6 +361,7 @@ def _extract_operator(self, ctx: Any) -> str:
"""Extract comparison operator"""
try:
text = self.get_context_text(ctx)
text_upper = text.upper()

# Check SQL constructs first (order matters for ISNOTNULL vs ISNULL)
sql_constructs = {
Expand All @@ -370,7 +373,7 @@ def _extract_operator(self, ctx: Any) -> str:
}

for construct, operator in sql_constructs.items():
if construct in text:
if construct in text_upper:
return operator

# Look for comparison operators
Expand All @@ -394,15 +397,16 @@ def _extract_value(self, ctx: Any) -> Any:
"""Extract value from comparison expression"""
try:
text = self.get_context_text(ctx)
text_upper = text.upper()

# Handle SQL constructs with specific parsing needs
if "IN(" in text:
if "IN(" in text_upper:
return self._extract_in_values(text)
elif "LIKE" in text:
elif "LIKE" in text_upper:
return self._extract_like_pattern(text)
elif "BETWEEN" in text:
elif "BETWEEN" in text_upper:
return self._extract_between_range(text)
elif "ISNULL" in text or "ISNOTNULL" in text:
elif "ISNULL" in text_upper or "ISNOTNULL" in text_upper:
return None

# Standard operator-based extraction
Expand Down Expand Up @@ -526,16 +530,24 @@ def _extract_in_values(self, text: str) -> List[Any]:

def _extract_like_pattern(self, text: str) -> str:
"""Extract pattern from LIKE clause"""
parts = text.split("LIKE", 1)
return parts[1].strip().strip("'\"") if len(parts) == 2 else ""
idx = text.upper().find("LIKE")
if idx == -1:
return ""
return text[idx + 4 :].strip().strip("'\"")

def _extract_between_range(self, text: str) -> Optional[Tuple[Any, Any]]:
"""Extract range values from BETWEEN clause"""
parts = text.split("BETWEEN", 1)
if len(parts) == 2 and "AND" in parts[1]:
range_values = parts[1].split("AND", 1)
if len(range_values) == 2:
return (self._parse_value(range_values[0].strip()), self._parse_value(range_values[1].strip()))
text_upper = text.upper()
between_idx = text_upper.find("BETWEEN")
if between_idx == -1:
return None
after = text[between_idx + 7 :]
after_upper = after.upper()
and_idx = after_upper.find("AND")
if and_idx != -1:
low = after[:and_idx].strip()
high = after[and_idx + 3 :].strip()
return (self._parse_value(low), self._parse_value(high))
return None


Expand Down
100 changes: 100 additions & 0 deletions tests/test_sql_parser_comprehensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,103 @@ def test_6_conditions_with_brackets(self):
for key in ["active", "deleted", "age", "name"]:
assert key in flat, f"Missing expected key '{key}' in filter: {f}"
assert "$or" in flat


class TestCaseInsensitiveOperators:
"""Test that SQL operators in WHERE clauses work regardless of case."""

# --- LIKE case variants ---

def test_like_lowercase(self):
sql = "SELECT * FROM col WHERE name like '%john%'"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "name" in f
assert "$regex" in f["name"]

def test_like_mixed_case(self):
sql = "SELECT * FROM col WHERE name Like '%john%'"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "name" in f
assert "$regex" in f["name"]

# --- IN case variants ---

def test_in_lowercase(self):
sql = "SELECT * FROM col WHERE status in ('a','b','c')"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "status" in f
assert "$in" in f["status"]

def test_in_mixed_case(self):
sql = "SELECT * FROM col WHERE status In ('a','b','c')"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "status" in f
assert "$in" in f["status"]

# --- BETWEEN case variants ---

def test_between_lowercase(self):
sql = "SELECT * FROM col WHERE age between 10 and 50"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "$and" in f
assert any("age" in cond and "$gte" in cond.get("age", {}) for cond in f["$and"])
assert any("age" in cond and "$lte" in cond.get("age", {}) for cond in f["$and"])

def test_between_mixed_case(self):
sql = "SELECT * FROM col WHERE age Between 10 And 50"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "$and" in f
assert any("age" in cond and "$gte" in cond.get("age", {}) for cond in f["$and"])
assert any("age" in cond and "$lte" in cond.get("age", {}) for cond in f["$and"])

# --- IS NULL / IS NOT NULL case variants ---

def test_is_null_lowercase(self):
sql = "SELECT * FROM col WHERE name is null"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "name" in f
assert f["name"] == {"$eq": None}

def test_is_not_null_lowercase(self):
sql = "SELECT * FROM col WHERE name is not null"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "name" in f
assert f["name"] == {"$ne": None}

# --- AND / OR case variants ---

def test_and_lowercase(self):
sql = "SELECT * FROM col WHERE age=30 and name='John'"
plan = SQLParser(sql).get_execution_plan()
assert plan.filter_stage == {"$and": [{"age": 30}, {"name": "John"}]}

def test_or_lowercase(self):
sql = "SELECT * FROM col WHERE age=30 or name='John'"
plan = SQLParser(sql).get_execution_plan()
assert plan.filter_stage == {"$or": [{"age": 30}, {"name": "John"}]}

# --- Mixed case operators in compound expressions ---

def test_like_and_bool_lowercase_operators(self):
sql = "SELECT * FROM col WHERE name like '%john%' and active=true"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "$and" in f
assert {"active": True} in f["$and"]
assert any("name" in cond and "$regex" in cond.get("name", {}) for cond in f["$and"])

def test_in_and_comparison_lowercase(self):
sql = "SELECT * FROM col WHERE status in ('a','b') and age>25"
plan = SQLParser(sql).get_execution_plan()
f = plan.filter_stage
assert "$and" in f
assert any("status" in cond and "$in" in cond.get("status", {}) for cond in f["$and"])
assert any("age" in cond and "$gt" in cond.get("age", {}) for cond in f["$and"])
Loading