Skip to content

Commit 0accaad

Browse files
author
Peng Ren
committed
Added parameters support
1 parent 333b5cc commit 0accaad

File tree

7 files changed

+281
-19
lines changed

7 files changed

+281
-19
lines changed

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,40 @@ row = cursor.fetchone()
147147
print(row['name']) # Access by column name
148148
```
149149

150+
### Query with Parameters
151+
152+
PyMongoSQL supports two styles of parameterized queries for safe value substitution:
153+
154+
**Positional Parameters with ?**
155+
156+
```python
157+
from pymongosql import connect
158+
159+
connection = connect(host="mongodb://localhost:27017/database")
160+
cursor = connection.cursor()
161+
162+
cursor.execute(
163+
'SELECT name, email FROM users WHERE age > ? AND status = ?',
164+
[25, 'active']
165+
)
166+
```
167+
168+
**Named Parameters with :name**
169+
170+
```python
171+
from pymongosql import connect
172+
173+
connection = connect(host="mongodb://localhost:27017/database")
174+
cursor = connection.cursor()
175+
176+
cursor.execute(
177+
'SELECT name, email FROM users WHERE age > :age AND status = :status',
178+
{'age': 25, 'status': 'active'}
179+
)
180+
```
181+
182+
Parameters are substituted into the MongoDB filter during execution, providing protection against injection attacks.
183+
150184
## Supported SQL Features
151185

152186
### SELECT Statements
@@ -201,8 +235,6 @@ This allows seamless integration between MongoDB data and Superset's BI capabili
201235

202236
**Note**: Currently PyMongoSQL focuses on Data Query Language (DQL) operations. The following SQL features are **not yet supported** but are planned for future releases:
203237

204-
- **Parameterized Queries**
205-
- Parameter substitution support (?, :pram, etc.)
206238
- **DML Operations** (Data Manipulation Language)
207239
- `INSERT`, `UPDATE`, `DELETE`
208240
- **DDL Operations** (Data Definition Language)

pymongosql/common.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
import logging
33
from abc import ABCMeta, abstractmethod
4-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
4+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
55

66
from .error import ProgrammingError
77

@@ -38,12 +38,12 @@ def description(
3838
def execute(
3939
self,
4040
operation: str,
41-
parameters: Optional[Dict[str, Any]] = None,
41+
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
4242
):
4343
raise NotImplementedError # pragma: no cover
4444

4545
@abstractmethod
46-
def executemany(self, operation: str, seq_of_parameters: List[Optional[Dict[str, Any]]]) -> None:
46+
def executemany(self, operation: str, seq_of_parameters: List[Union[Sequence[Any], Dict[str, Any]]]) -> None:
4747
raise NotImplementedError # pragma: no cover
4848

4949
@abstractmethod

pymongosql/cursor.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,20 @@ def _check_closed(self) -> None:
7575
if self._is_closed:
7676
raise ProgrammingError("Cursor is closed")
7777

78-
def execute(self: _T, operation: str, parameters: Optional[Dict[str, Any]] = None) -> _T:
78+
def execute(self: _T, operation: str, parameters: Optional[Any] = None) -> _T:
7979
"""Execute a SQL statement
8080
8181
Args:
8282
operation: SQL statement to execute
83-
parameters: Parameters for the SQL statement (not yet implemented)
83+
parameters: Parameters to substitute placeholders in the SQL
84+
- Sequence for positional parameters with ? placeholders
85+
- Dict for named parameters with :name placeholders
8486
8587
Returns:
8688
Self for method chaining
8789
"""
8890
self._check_closed()
8991

90-
if parameters:
91-
_logger.warning("Parameter substitution not yet implemented, ignoring parameters")
92-
9392
try:
9493
# Create execution context
9594
context = ExecutionContext(operation, self.mode)
@@ -98,7 +97,7 @@ def execute(self: _T, operation: str, parameters: Optional[Dict[str, Any]] = Non
9897
strategy = ExecutionPlanFactory.get_strategy(context)
9998

10099
# Execute using selected strategy (Standard or Subquery)
101-
result = strategy.execute(context, self.connection)
100+
result = strategy.execute(context, self.connection, parameters)
102101

103102
# Store execution plan for reference
104103
self._current_execution_plan = strategy.execution_plan

pymongosql/executor.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from abc import ABC, abstractmethod
44
from dataclasses import dataclass
5-
from typing import Any, Dict, Optional
5+
from typing import Any, Dict, Optional, Sequence, Union
66

77
from pymongo.errors import PyMongoError
88

@@ -19,6 +19,7 @@ class ExecutionContext:
1919

2020
query: str
2121
execution_mode: str = "standard"
22+
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None
2223

2324
def __repr__(self) -> str:
2425
return f"ExecutionContext(mode={self.execution_mode}, " f"query={self.query})"
@@ -38,13 +39,15 @@ def execute(
3839
self,
3940
context: ExecutionContext,
4041
connection: Any,
42+
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
4143
) -> Optional[Dict[str, Any]]:
4244
"""
4345
Execute query and return result set.
4446
4547
Args:
4648
context: ExecutionContext with query and subquery info
4749
connection: MongoDB connection
50+
parameters: Sequence for positional (?) or Dict for named (:param) parameters
4851
4952
Returns:
5053
command_result with query results
@@ -86,19 +89,59 @@ def _parse_sql(self, sql: str) -> ExecutionPlan:
8689
_logger.error(f"SQL parsing failed: {e}")
8790
raise SqlSyntaxError(f"Failed to parse SQL: {e}")
8891

89-
def _execute_execution_plan(self, execution_plan: ExecutionPlan, db: Any) -> Optional[Dict[str, Any]]:
92+
def _replace_placeholders(self, obj: Any, parameters: Sequence[Any]) -> Any:
93+
"""Recursively replace ? placeholders with parameter values in filter/projection dicts"""
94+
param_index = [0] # Use list to allow modification in nested function
95+
96+
def replace_recursive(value: Any) -> Any:
97+
if isinstance(value, str):
98+
# Replace ? with the next parameter value
99+
if value == "?":
100+
if param_index[0] < len(parameters):
101+
result = parameters[param_index[0]]
102+
param_index[0] += 1
103+
return result
104+
else:
105+
raise ProgrammingError(
106+
f"Not enough parameters provided: expected at least {param_index[0] + 1}"
107+
)
108+
return value
109+
elif isinstance(value, dict):
110+
return {k: replace_recursive(v) for k, v in value.items()}
111+
elif isinstance(value, list):
112+
return [replace_recursive(item) for item in value]
113+
else:
114+
return value
115+
116+
return replace_recursive(obj)
117+
118+
def _execute_execution_plan(
119+
self,
120+
execution_plan: ExecutionPlan,
121+
db: Any,
122+
parameters: Optional[Sequence[Any]] = None,
123+
) -> Optional[Dict[str, Any]]:
90124
"""Execute an ExecutionPlan against MongoDB using db.command"""
91125
try:
92126
# Get database
93127
if not execution_plan.collection:
94128
raise ProgrammingError("No collection specified in query")
95129

130+
# Replace placeholders with parameters in filter_stage only (not in projection)
131+
filter_stage = execution_plan.filter_stage or {}
132+
133+
if parameters:
134+
# Positional parameters with ? (named parameters are converted to positional in execute())
135+
filter_stage = self._replace_placeholders(filter_stage, parameters)
136+
137+
projection_stage = execution_plan.projection_stage or {}
138+
96139
# Build MongoDB find command
97-
find_command = {"find": execution_plan.collection, "filter": execution_plan.filter_stage or {}}
140+
find_command = {"find": execution_plan.collection, "filter": filter_stage}
98141

99142
# Apply projection if specified
100-
if execution_plan.projection_stage:
101-
find_command["projection"] = execution_plan.projection_stage
143+
if projection_stage:
144+
find_command["projection"] = projection_stage
102145

103146
# Apply sort if specified
104147
if execution_plan.sort_stage:
@@ -135,14 +178,28 @@ def execute(
135178
self,
136179
context: ExecutionContext,
137180
connection: Any,
181+
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
138182
) -> Optional[Dict[str, Any]]:
139183
"""Execute standard query directly against MongoDB"""
140184
_logger.debug(f"Using standard execution for query: {context.query[:100]}")
141185

186+
# Preprocess query to convert named parameters to positional
187+
processed_query = context.query
188+
processed_params = parameters
189+
if isinstance(parameters, dict):
190+
# Convert :param_name to ? for parsing
191+
import re
192+
193+
param_names = re.findall(r":(\w+)", context.query)
194+
# Convert dict parameters to list in order of appearance
195+
processed_params = [parameters[name] for name in param_names]
196+
# Replace :param_name with ?
197+
processed_query = re.sub(r":(\w+)", "?", context.query)
198+
142199
# Parse the query
143-
self._execution_plan = self._parse_sql(context.query)
200+
self._execution_plan = self._parse_sql(processed_query)
144201

145-
return self._execute_execution_plan(self._execution_plan, connection.database)
202+
return self._execute_execution_plan(self._execution_plan, connection.database, processed_params)
146203

147204

148205
class ExecutionPlanFactory:

pymongosql/superset_mongodb/executor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def execute(
4444
self,
4545
context: ExecutionContext,
4646
connection: Any,
47+
parameters: Optional[Any] = None,
4748
) -> Optional[Dict[str, Any]]:
4849
"""Execute query in two stages: MongoDB for subquery, intermediate DB for outer query"""
4950
_logger.debug(f"Using subquery execution for query: {context.query[:100]}")
@@ -54,7 +55,7 @@ def execute(
5455
# If no subquery detected, fall back to standard execution
5556
if not query_info.has_subquery:
5657
_logger.debug("No subquery detected, falling back to standard execution")
57-
return super().execute(context, connection)
58+
return super().execute(context, connection, parameters)
5859

5960
# Stage 1: Execute MongoDB subquery
6061
mongo_query = query_info.subquery_text

tests/test_cursor.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,29 @@ def test_execute_with_nested_field_alias(self, conn):
398398
rows = cursor.result_set.fetchall()
399399
assert len(rows) == 3
400400
assert len(rows[0]) == 2 # Should have 2 columns
401+
402+
def test_execute_with_positional_parameters(self, conn):
403+
"""Test executing SELECT with positional parameters (?)"""
404+
sql = "SELECT name, email FROM users WHERE age > ? AND active = ?"
405+
cursor = conn.cursor()
406+
result = cursor.execute(sql, [25, True])
407+
408+
assert result == cursor # execute returns self
409+
assert isinstance(cursor.result_set, ResultSet)
410+
411+
rows = cursor.result_set.fetchall()
412+
assert len(rows) > 0 # Should have results matching the filter
413+
assert len(rows[0]) == 2 # Should have name and email columns
414+
415+
def test_execute_with_named_parameters(self, conn):
416+
"""Test executing SELECT with named parameters (:name)"""
417+
sql = "SELECT name, email FROM users WHERE age > :min_age AND active = :is_active"
418+
cursor = conn.cursor()
419+
result = cursor.execute(sql, {"min_age": 25, "is_active": True})
420+
421+
assert result == cursor # execute returns self
422+
assert isinstance(cursor.result_set, ResultSet)
423+
424+
rows = cursor.result_set.fetchall()
425+
assert len(rows) > 0 # Should have results matching the filter
426+
assert len(rows[0]) == 2 # Should have name and email columns

0 commit comments

Comments
 (0)