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
44 changes: 39 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
[![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![codecov](https://codecov.io/gh/passren/PyMongoSQL/branch/main/graph/badge.svg?token=2CTRL80NP2)](https://codecov.io/gh/passren/PyMongoSQL)
[![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](https://github.com/passren/PyMongoSQL/blob/0.1.2/LICENSE)
[![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![Python Version](https://img.shields.io/badge/python-3.9|3.10|3.11|3.12|3.13|3.14-blue.svg)](https://www.python.org/downloads/)
[![Downloads](https://static.pepy.tech/badge/pymongosql/month)](https://pepy.tech/projects/pymongosql)
[![MongoDB](https://img.shields.io/badge/MongoDB-7.0+-green.svg)](https://www.mongodb.com/)
[![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1.4+_2.0+-darkgreen.svg)](https://www.sqlalchemy.org/)
[![Superset](https://img.shields.io/badge/Apache_Superset-1.0+-blue.svg)](https://superset.apache.org/docs/6.0.0/configuration/databases)
[![MongoDB](https://img.shields.io/badge/MongoDB-7.0+|8.0+-green.svg)](https://www.mongodb.com/)
[![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1.4+|2.0+-darkgreen.svg)](https://www.sqlalchemy.org/)
[![Superset](https://img.shields.io/badge/Apache_Superset->1.0-blue.svg)](https://superset.apache.org/docs/6.0.0/configuration/databases)

PyMongoSQL is a Python [DB API 2.0 (PEP 249)](https://www.python.org/dev/peps/pep-0249/) client for [MongoDB](https://www.mongodb.com/). It provides a familiar SQL interface to MongoDB, allowing developers to use SQL to interact with MongoDB collections.

Expand All @@ -35,7 +35,7 @@ PyMongoSQL implements the DB API 2.0 interfaces to provide SQL-like access to Mo

## Requirements

- **Python**: 3.9, 3.10, 3.11, 3.12, 3.13+
- **Python**: 3.9, 3.10, 3.11, 3.12, 3.13, 3.14
- **MongoDB**: 7.0+

## Dependencies
Expand Down Expand Up @@ -92,6 +92,7 @@ pip install -e .
- [UPDATE Statements](#update-statements)
- [DELETE Statements](#delete-statements)
- [View Management](#view-management)
- [Explain Statement](#explain-statement)
- [Transaction Support](#transaction-support)
- [SQL to MongoDB Mapping](#sql-to-mongodb-mapping)
- [Apache Superset Integration](#apache-superset-integration)
Expand Down Expand Up @@ -542,6 +543,38 @@ cursor.execute("DROP VIEW active_users")

**Note:** The pipeline must be a valid JSON array string enclosed in single quotes. `CREATE VIEW` maps to `db.command({"create": view_name, "viewOn": collection, "pipeline": [...]})` and `DROP VIEW` maps to `db.command({"drop": view_name})`.

### Explain Statement

Prefix any `SELECT` statement with `EXPLAIN` to inspect the MongoDB [query plan](https://www.mongodb.com/docs/compass/query-plan/). PyMongoSQL wraps the inner command with MongoDB's [`explain`](https://www.mongodb.com/docs/manual/reference/command/explain/) command and flattens the winning plan tree into a two-column result set (`stage`, `details`) that renders well in table views (e.g. Apache Superset, DB clients).

```python
# Default verbosity: queryPlanner
cursor.execute("EXPLAIN SELECT * FROM users WHERE age > 21")
for stage, details in cursor.fetchall():
print(stage, details)

# Request executionStats (actual timing, docs/keys examined) or allPlansExecution
cursor.execute("EXPLAIN (verbosity executionStats) SELECT * FROM users WHERE age > 21")
```

**Supported verbosities** (grammar-native options, unquoted identifiers):

| Verbosity | What you get |
|---|---|
| `queryPlanner` _(default)_ | Winning plan, rejected plans, namespace, parsedQuery |
| `executionStats` | `queryPlanner` output + execution time, documents returned/examined, index keys examined |
| `allPlansExecution` | `executionStats` for the winning plan **and** each rejected candidate plan |

Example output rows (`queryPlanner`):

```
stage details
namespace mydb.users
parsedQuery {"age": {"$gt": 21}}
├─ COLLSCAN {"direction": "forward", "filter": {...}}
rejectedPlans []
```

### Transaction Support

PyMongoSQL supports DB API 2.0 transactions for ACID-compliant database operations. Use the `begin()`, `commit()`, and `rollback()` methods to manage transactions:
Expand Down Expand Up @@ -588,6 +621,7 @@ The table below shows how PyMongoSQL translates SQL operations into MongoDB comm
| `DELETE FROM col WHERE ...` | `{delete: col, deletes: [{q: filter, limit: 0}]}` | `db.command("delete", ...)` |
| `CREATE VIEW v ON col AS '[...]'` | `{create: v, viewOn: col, pipeline: [...]}` | `db.command("create", ...)` |
| `DROP VIEW v` | `{drop: v}` | `db.command("drop", ...)` |
| `EXPLAIN <select>` | `{explain: <find\|aggregate cmd>, verbosity: "queryPlanner"}` | `db.command("explain", ...)` |

### SQL Clauses to MongoDB Query Components

Expand Down
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.6.0"
__version__: str = "0.7.0"

# Globals https://www.python.org/dev/peps/pep-0249/#globals
apilevel: str = "2.0"
Expand Down
86 changes: 85 additions & 1 deletion pymongosql/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .helper import SQLHelper
from .retry import execute_with_retry
from .sql.delete_builder import DeleteExecutionPlan
from .sql.explain_builder import ExplainExecutionPlan
from .sql.insert_builder import InsertExecutionPlan
from .sql.parser import SQLParser
from .sql.query_builder import QueryExecutionPlan
Expand Down Expand Up @@ -758,10 +759,93 @@ def execute(
return self._execute_execution_plan(self._execution_plan, connection, parameters)


class ExplainExecution(ExecutionStrategy):
"""Execution strategy for ``EXPLAIN [ (opt val, ...) ] <statement>`` wrappers.

Parses via :class:`SQLParser` (grammar-native EXPLAIN production) to obtain
an :class:`ExplainExecutionPlan`, delegates command construction and result
flattening to the plan, and runs the resulting ``explain`` command through
the shared connection/retry path.
"""

_EXPLAIN_PATTERN = re.compile(r"^\s*EXPLAIN\b", re.IGNORECASE)

@property
def execution_plan(self) -> QueryExecutionPlan:
return self._execution_plan

def supports(self, context: ExecutionContext) -> bool:
return bool(self._EXPLAIN_PATTERN.match(context.query))

def _parse_sql(self, sql: str) -> ExplainExecutionPlan:
try:
parser = SQLParser(sql)
plan = parser.get_execution_plan()
if not isinstance(plan, ExplainExecutionPlan):
raise SqlSyntaxError("Expected EXPLAIN execution plan")
if not plan.validate():
raise SqlSyntaxError("Generated EXPLAIN plan is invalid")
return plan
except SqlSyntaxError:
raise
except Exception as e:
_logger.error(f"SQL parsing failed: {e}")
raise SqlSyntaxError(f"Failed to parse SQL: {e}")

def execute(
self,
context: ExecutionContext,
connection: Any,
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
_logger.debug(f"Using explain execution for query: {context.query[:100]}")

# Normalize named parameters to positional, matching StandardQueryExecution.
processed_query = context.query
processed_params = parameters
if isinstance(parameters, dict):
param_names = re.findall(r":(\w+)", context.query)
processed_params = [parameters[name] for name in param_names]
processed_query = re.sub(r":(\w+)", "?", context.query)

explain_plan = self._parse_sql(processed_query)
# Store the synthesized result plan (QueryExecutionPlan) so the cursor
# can wire it directly into the ResultSet for column description.
self._execution_plan = explain_plan.result_plan

# Build the explain command (validates inner plan is a supported SELECT).
explain_cmd = explain_plan.build_command(processed_params)

if not connection:
raise OperationalError("No connection provided")

_logger.debug(f"Executing MongoDB explain command: {explain_cmd}")

try:
explain_result = _run_db_command(connection.database, explain_cmd, connection, "explain command")
except PyMongoError as e:
_logger.error(f"MongoDB explain execution failed: {e}")
raise DatabaseError(f"Explain execution failed: {e}")

# Return flattened rows as a command result. The cursor handles
# ExplainExecutionPlan -> result_plan translation when wiring the ResultSet.
return {
"cursor": {"id": 0, "firstBatch": explain_plan.flatten_result(explain_result)},
"ok": 1,
}


class ExecutionPlanFactory:
"""Factory for creating appropriate execution strategy based on query context"""

_strategies = [ViewExecution(), StandardQueryExecution(), InsertExecution(), UpdateExecution(), DeleteExecution()]
_strategies = [
ExplainExecution(),
ViewExecution(),
StandardQueryExecution(),
InsertExecution(),
UpdateExecution(),
DeleteExecution(),
]

@classmethod
def get_strategy(cls, context: ExecutionContext) -> ExecutionStrategy:
Expand Down
39 changes: 39 additions & 0 deletions pymongosql/sql/ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def __init__(self) -> None:
self._update_parse_result = UpdateParseResult.for_visitor()
# Track current statement kind generically so UPDATE/DELETE can reuse this
self._current_operation: str = "select" # expected values: select | insert | update | delete
# EXPLAIN wrapper state (grammar: root : EXPLAIN? (options)? statement EOF)
self._is_explain: bool = False
self._explain_options: Dict[str, str] = {}
self._handlers = self._initialize_handlers()

def _initialize_handlers(self) -> Dict[str, BaseHandler]:
Expand Down Expand Up @@ -69,11 +72,47 @@ def current_operation(self) -> str:
"""Get the current operation type (select, insert, delete, or update)"""
return self._current_operation

@property
def is_explain(self) -> bool:
"""Whether the parsed statement was wrapped in EXPLAIN."""
return self._is_explain

@property
def explain_options(self) -> Dict[str, str]:
"""Options collected from ``EXPLAIN (key value, ...)`` clause."""
return dict(self._explain_options)

def visitRoot(self, ctx: PartiQLParser.RootContext) -> Any:
"""Visit root node and process child nodes"""
_logger.debug("Starting to parse SQL query")
# Reset to default SELECT operation at the start of each query
self._current_operation = "select"
self._is_explain = False
self._explain_options = {}

# Detect EXPLAIN wrapper per grammar:
# root : (EXPLAIN (PAREN_LEFT explainOption (COMMA explainOption)* PAREN_RIGHT)? )? statement EOF;
try:
explain_token = ctx.EXPLAIN() if hasattr(ctx, "EXPLAIN") else None
except Exception:
explain_token = None

if explain_token is not None:
self._is_explain = True
try:
option_ctxs = ctx.explainOption() or []
except Exception:
option_ctxs = []
for opt in option_ctxs:
try:
param_tok = getattr(opt, "param", None)
value_tok = getattr(opt, "value", None)
if param_tok is not None and value_tok is not None:
self._explain_options[param_tok.text] = value_tok.text
except Exception as e:
_logger.warning(f"Error parsing explainOption: {e}")
_logger.debug(f"EXPLAIN detected with options: {self._explain_options}")

try:
result = self.visitChildren(ctx)
return result
Expand Down
Loading
Loading