diff --git a/pymongosql/__init__.py b/pymongosql/__init__.py index af3c27c..4b73cce 100644 --- a/pymongosql/__init__.py +++ b/pymongosql/__init__.py @@ -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" diff --git a/pymongosql/sql/handler.py b/pymongosql/sql/handler.py index d42f480..e01370d 100644 --- a/pymongosql/sql/handler.py +++ b/pymongosql/sql/handler.py @@ -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) @@ -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 @@ -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 = { @@ -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 @@ -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 @@ -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 diff --git a/tests/test_sql_parser_comprehensive.py b/tests/test_sql_parser_comprehensive.py index 48ef87c..ec19beb 100644 --- a/tests/test_sql_parser_comprehensive.py +++ b/tests/test_sql_parser_comprehensive.py @@ -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"])