Skip to content

Commit 8a890fe

Browse files
author
Peng Ren
committed
Add explain plan support
1 parent 85e1167 commit 8a890fe

6 files changed

Lines changed: 642 additions & 4 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ pip install -e .
9292
- [UPDATE Statements](#update-statements)
9393
- [DELETE Statements](#delete-statements)
9494
- [View Management](#view-management)
95+
- [Explain Statement](#explain-statement)
9596
- [Transaction Support](#transaction-support)
9697
- [SQL to MongoDB Mapping](#sql-to-mongodb-mapping)
9798
- [Apache Superset Integration](#apache-superset-integration)
@@ -542,6 +543,38 @@ cursor.execute("DROP VIEW active_users")
542543

543544
**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})`.
544545

546+
### Explain Statement
547+
548+
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).
549+
550+
```python
551+
# Default verbosity: queryPlanner
552+
cursor.execute("EXPLAIN SELECT * FROM users WHERE age > 21")
553+
for stage, details in cursor.fetchall():
554+
print(stage, details)
555+
556+
# Request executionStats (actual timing, docs/keys examined) or allPlansExecution
557+
cursor.execute("EXPLAIN (verbosity executionStats) SELECT * FROM users WHERE age > 21")
558+
```
559+
560+
**Supported verbosities** (grammar-native options, unquoted identifiers):
561+
562+
| Verbosity | What you get |
563+
|---|---|
564+
| `queryPlanner` _(default)_ | Winning plan, rejected plans, namespace, parsedQuery |
565+
| `executionStats` | `queryPlanner` output + execution time, documents returned/examined, index keys examined |
566+
| `allPlansExecution` | `executionStats` for the winning plan **and** each rejected candidate plan |
567+
568+
Example output rows (`queryPlanner`):
569+
570+
```
571+
stage details
572+
namespace mydb.users
573+
parsedQuery {"age": {"$gt": 21}}
574+
├─ COLLSCAN {"direction": "forward", "filter": {...}}
575+
rejectedPlans []
576+
```
577+
545578
### Transaction Support
546579

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

592626
### SQL Clauses to MongoDB Query Components
593627

pymongosql/executor.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .helper import SQLHelper
1212
from .retry import execute_with_retry
1313
from .sql.delete_builder import DeleteExecutionPlan
14+
from .sql.explain_builder import ExplainExecutionPlan
1415
from .sql.insert_builder import InsertExecutionPlan
1516
from .sql.parser import SQLParser
1617
from .sql.query_builder import QueryExecutionPlan
@@ -758,10 +759,93 @@ def execute(
758759
return self._execute_execution_plan(self._execution_plan, connection, parameters)
759760

760761

762+
class ExplainExecution(ExecutionStrategy):
763+
"""Execution strategy for ``EXPLAIN [ (opt val, ...) ] <statement>`` wrappers.
764+
765+
Parses via :class:`SQLParser` (grammar-native EXPLAIN production) to obtain
766+
an :class:`ExplainExecutionPlan`, delegates command construction and result
767+
flattening to the plan, and runs the resulting ``explain`` command through
768+
the shared connection/retry path.
769+
"""
770+
771+
_EXPLAIN_PATTERN = re.compile(r"^\s*EXPLAIN\b", re.IGNORECASE)
772+
773+
@property
774+
def execution_plan(self) -> QueryExecutionPlan:
775+
return self._execution_plan
776+
777+
def supports(self, context: ExecutionContext) -> bool:
778+
return bool(self._EXPLAIN_PATTERN.match(context.query))
779+
780+
def _parse_sql(self, sql: str) -> ExplainExecutionPlan:
781+
try:
782+
parser = SQLParser(sql)
783+
plan = parser.get_execution_plan()
784+
if not isinstance(plan, ExplainExecutionPlan):
785+
raise SqlSyntaxError("Expected EXPLAIN execution plan")
786+
if not plan.validate():
787+
raise SqlSyntaxError("Generated EXPLAIN plan is invalid")
788+
return plan
789+
except SqlSyntaxError:
790+
raise
791+
except Exception as e:
792+
_logger.error(f"SQL parsing failed: {e}")
793+
raise SqlSyntaxError(f"Failed to parse SQL: {e}")
794+
795+
def execute(
796+
self,
797+
context: ExecutionContext,
798+
connection: Any,
799+
parameters: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
800+
) -> Optional[Dict[str, Any]]:
801+
_logger.debug(f"Using explain execution for query: {context.query[:100]}")
802+
803+
# Normalize named parameters to positional, matching StandardQueryExecution.
804+
processed_query = context.query
805+
processed_params = parameters
806+
if isinstance(parameters, dict):
807+
param_names = re.findall(r":(\w+)", context.query)
808+
processed_params = [parameters[name] for name in param_names]
809+
processed_query = re.sub(r":(\w+)", "?", context.query)
810+
811+
explain_plan = self._parse_sql(processed_query)
812+
# Store the synthesized result plan (QueryExecutionPlan) so the cursor
813+
# can wire it directly into the ResultSet for column description.
814+
self._execution_plan = explain_plan.result_plan
815+
816+
# Build the explain command (validates inner plan is a supported SELECT).
817+
explain_cmd = explain_plan.build_command(processed_params)
818+
819+
if not connection:
820+
raise OperationalError("No connection provided")
821+
822+
_logger.debug(f"Executing MongoDB explain command: {explain_cmd}")
823+
824+
try:
825+
explain_result = _run_db_command(connection.database, explain_cmd, connection, "explain command")
826+
except PyMongoError as e:
827+
_logger.error(f"MongoDB explain execution failed: {e}")
828+
raise DatabaseError(f"Explain execution failed: {e}")
829+
830+
# Return flattened rows as a command result. The cursor handles
831+
# ExplainExecutionPlan -> result_plan translation when wiring the ResultSet.
832+
return {
833+
"cursor": {"id": 0, "firstBatch": explain_plan.flatten_result(explain_result)},
834+
"ok": 1,
835+
}
836+
837+
761838
class ExecutionPlanFactory:
762839
"""Factory for creating appropriate execution strategy based on query context"""
763840

764-
_strategies = [ViewExecution(), StandardQueryExecution(), InsertExecution(), UpdateExecution(), DeleteExecution()]
841+
_strategies = [
842+
ExplainExecution(),
843+
ViewExecution(),
844+
StandardQueryExecution(),
845+
InsertExecution(),
846+
UpdateExecution(),
847+
DeleteExecution(),
848+
]
765849

766850
@classmethod
767851
def get_strategy(cls, context: ExecutionContext) -> ExecutionStrategy:

pymongosql/sql/ast.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def __init__(self) -> None:
3838
self._update_parse_result = UpdateParseResult.for_visitor()
3939
# Track current statement kind generically so UPDATE/DELETE can reuse this
4040
self._current_operation: str = "select" # expected values: select | insert | update | delete
41+
# EXPLAIN wrapper state (grammar: root : EXPLAIN? (options)? statement EOF)
42+
self._is_explain: bool = False
43+
self._explain_options: Dict[str, str] = {}
4144
self._handlers = self._initialize_handlers()
4245

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

75+
@property
76+
def is_explain(self) -> bool:
77+
"""Whether the parsed statement was wrapped in EXPLAIN."""
78+
return self._is_explain
79+
80+
@property
81+
def explain_options(self) -> Dict[str, str]:
82+
"""Options collected from ``EXPLAIN (key value, ...)`` clause."""
83+
return dict(self._explain_options)
84+
7285
def visitRoot(self, ctx: PartiQLParser.RootContext) -> Any:
7386
"""Visit root node and process child nodes"""
7487
_logger.debug("Starting to parse SQL query")
7588
# Reset to default SELECT operation at the start of each query
7689
self._current_operation = "select"
90+
self._is_explain = False
91+
self._explain_options = {}
92+
93+
# Detect EXPLAIN wrapper per grammar:
94+
# root : (EXPLAIN (PAREN_LEFT explainOption (COMMA explainOption)* PAREN_RIGHT)? )? statement EOF;
95+
try:
96+
explain_token = ctx.EXPLAIN() if hasattr(ctx, "EXPLAIN") else None
97+
except Exception:
98+
explain_token = None
99+
100+
if explain_token is not None:
101+
self._is_explain = True
102+
try:
103+
option_ctxs = ctx.explainOption() or []
104+
except Exception:
105+
option_ctxs = []
106+
for opt in option_ctxs:
107+
try:
108+
param_tok = getattr(opt, "param", None)
109+
value_tok = getattr(opt, "value", None)
110+
if param_tok is not None and value_tok is not None:
111+
self._explain_options[param_tok.text] = value_tok.text
112+
except Exception as e:
113+
_logger.warning(f"Error parsing explainOption: {e}")
114+
_logger.debug(f"EXPLAIN detected with options: {self._explain_options}")
115+
77116
try:
78117
result = self.visitChildren(ctx)
79118
return result

0 commit comments

Comments
 (0)