Skip to content

Commit 74df878

Browse files
passrenPeng Ren
andauthored
0.2.5 Added parameters support in cursor (#7)
* Update readme * Added parameters support * Fixed vulnerable issue --------- Co-authored-by: Peng Ren <[email protected]>
1 parent 468df54 commit 74df878

File tree

9 files changed

+321
-47
lines changed

9 files changed

+321
-47
lines changed

README.md

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -107,36 +107,80 @@ from pymongosql import connect
107107

108108
with connect(host="mongodb://localhost:27017/database") as conn:
109109
with conn.cursor() as cursor:
110+
cursor.execute('SELECT COUNT(*) as total FROM users')
111+
result = cursor.fetchone()
112+
print(f"Total users: {result[0]}")
113+
```
114+
115+
### Using DictCursor for Dictionary Results
116+
117+
```python
118+
from pymongosql import connect
119+
from pymongosql.cursor import DictCursor
120+
121+
with connect(host="mongodb://localhost:27017/database") as conn:
122+
with conn.cursor(DictCursor) as cursor:
110123
cursor.execute('SELECT COUNT(*) as total FROM users')
111124
result = cursor.fetchone()
112125
print(f"Total users: {result['total']}")
113126
```
114127

128+
### Cursor vs DictCursor
129+
130+
PyMongoSQL provides two cursor types for different result formats:
131+
132+
**Cursor** (default) - Returns results as tuples:
133+
```python
134+
cursor = connection.cursor()
135+
cursor.execute('SELECT name, email FROM users')
136+
row = cursor.fetchone()
137+
print(row[0]) # Access by index
138+
```
139+
140+
**DictCursor** - Returns results as dict:
141+
```python
142+
from pymongosql.cursor import DictCursor
143+
144+
cursor = connection.cursor(DictCursor)
145+
cursor.execute('SELECT name, email FROM users')
146+
row = cursor.fetchone()
147+
print(row['name']) # Access by column name
148+
```
149+
115150
### Query with Parameters
116151

152+
PyMongoSQL supports two styles of parameterized queries for safe value substitution:
153+
154+
**Positional Parameters with ?**
155+
117156
```python
118157
from pymongosql import connect
119158

120159
connection = connect(host="mongodb://localhost:27017/database")
121160
cursor = connection.cursor()
122161

123-
# Parameterized queries for security
124-
min_age = 18
125-
status = 'active'
126-
127-
cursor.execute('''
128-
SELECT name, email, created_at
129-
FROM users
130-
WHERE age >= ? AND status = ?
131-
''', [min_age, status])
132-
133-
users = cursor.fetchmany(5) # Fetch first 5 results
134-
while users:
135-
for user in users:
136-
print(f"User: {user['name']} ({user['email']})")
137-
users = cursor.fetchmany(5) # Fetch next 5
162+
cursor.execute(
163+
'SELECT name, email FROM users WHERE age > ? AND status = ?',
164+
[25, 'active']
165+
)
138166
```
139167

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+
140184
## Supported SQL Features
141185

142186
### SELECT Statements
@@ -166,19 +210,6 @@ while users:
166210
- LIMIT: `LIMIT 10`
167211
- Combined: `ORDER BY created_at DESC LIMIT 5`
168212

169-
## Limitations & Roadmap
170-
171-
**Note**: Currently PyMongoSQL focuses on Data Query Language (DQL) operations. The following SQL features are **not yet supported** but are planned for future releases:
172-
173-
- **DML Operations** (Data Manipulation Language)
174-
- `INSERT`, `UPDATE`, `DELETE`
175-
- **DDL Operations** (Data Definition Language)
176-
- `CREATE TABLE/COLLECTION`, `DROP TABLE/COLLECTION`
177-
- `CREATE INDEX`, `DROP INDEX`
178-
- `LIST TABLES/COLLECTIONS`
179-
180-
These features are on our development roadmap and contributions are welcome!
181-
182213
## Apache Superset Integration
183214

184215
PyMongoSQL can be used as a database driver in Apache Superset for querying and visualizing MongoDB data:
@@ -200,6 +231,19 @@ PyMongoSQL can be used as a database driver in Apache Superset for querying and
200231

201232
This allows seamless integration between MongoDB data and Superset's BI capabilities without requiring data migration to traditional SQL databases.
202233

234+
<h2 style="color: red;">Limitations & Roadmap</h2>
235+
236+
**Note**: Currently PyMongoSQL focuses on Data Query Language (DQL) operations. The following SQL features are **not yet supported** but are planned for future releases:
237+
238+
- **DML Operations** (Data Manipulation Language)
239+
- `INSERT`, `UPDATE`, `DELETE`
240+
- **DDL Operations** (Data Definition Language)
241+
- `CREATE TABLE/COLLECTION`, `DROP TABLE/COLLECTION`
242+
- `CREATE INDEX`, `DROP INDEX`
243+
- `LIST TABLES/COLLECTIONS`
244+
245+
These features are on our development roadmap and contributions are welcome!
246+
203247
## Contributing
204248

205249
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

pymongosql/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
if TYPE_CHECKING:
77
from .connection import Connection
88

9-
__version__: str = "0.2.4"
9+
__version__: str = "0.2.5"
1010

1111
# Globals https://www.python.org/dev/peps/pep-0249/#globals
1212
apilevel: str = "2.0"

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/run_test_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ def main():
324324
setup_test_data()
325325
print(f"\n[SUCCESS] MongoDB {version} test instance is ready!")
326326
print(
327-
f"Connection: mongodb://{TEST_USERNAME}:{TEST_PASSWORD}@{MONGODB_HOST}:{MONGODB_PORT}/{MONGODB_DATABASE}?authSource={TEST_AUTH_SOURCE}" # noqa: E501
327+
f"Connection: mongodb://{MONGODB_HOST}:{MONGODB_PORT}/{MONGODB_DATABASE}?authSource={TEST_AUTH_SOURCE}" # noqa: E501
328328
)
329329
else:
330330
print("[ERROR] Failed to create database user")

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)