Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a88b308
Implement MQL panel for Django Debug Toolbar
aclark4life Feb 18, 2026
8328eda
Address review feedback
aclark4life Feb 25, 2026
a7c9ec7
Delete .coverage
timgraham Feb 27, 2026
4bade48
Address review feedback (🤖 assisted)
aclark4life Feb 27, 2026
86b57e1
Add tests based on sql panel tests (🤖 assisted)
aclark4life Mar 3, 2026
8423a65
Jib review fixes
aclark4life Mar 7, 2026
8885aa4
readme edits
aclark4life Mar 9, 2026
22ba13d
Update gitignore
aclark4life Mar 9, 2026
352bf3b
Address review feedback
aclark4life Mar 16, 2026
4de35fe
Remove find
aclark4life Mar 18, 2026
8186c5a
Fix convert_documents_to_table and add test
aclark4life Mar 18, 2026
a60193e
Address review feedback
aclark4life Mar 18, 2026
fc8cc25
Address review feedback
aclark4life Mar 24, 2026
b44209c
Add template_info support to MQL panel
aclark4life Mar 24, 2026
246f537
Address review feedback
aclark4life Mar 24, 2026
1f27725
Move template_info above stacktrace
aclark4life Mar 24, 2026
4777e75
More verbose test run output
aclark4life Mar 24, 2026
b58f94c
Add test coverage harness
aclark4life Mar 24, 2026
7c8508e
Rename select -> aggregate
aclark4life Mar 24, 2026
0a8e660
Fix package version resolution for coverage
aclark4life Mar 24, 2026
a6c71fd
Move utils tests from test_panel -> test_utils
aclark4life Mar 25, 2026
63bbdcd
Move forms tests from test_utils -> tests_forms
aclark4life Mar 25, 2026
48c9058
Add QueryPartsTests
aclark4life Mar 25, 2026
043fdf1
Rename mql select -> mql query
aclark4life Mar 26, 2026
f6b9dba
Address review feedback
aclark4life Mar 26, 2026
5b6a8e5
Move MQL panel tests to tests/debug_toolbar/
aclark4life Mar 26, 2026
fa11ab2
Rename debug_toolbar.panels.mql/* -> mql_panel/*
aclark4life Mar 26, 2026
5c3e9b7
Update readme
aclark4life Mar 26, 2026
048534f
Refactor mql form clean() method
aclark4life Mar 27, 2026
783a966
Address review feedback
aclark4life Mar 27, 2026
5b758c1
Format mql_query and fix dhtml pre-commit hook
aclark4life Mar 31, 2026
7bc42a9
Alpha-sort class methods
aclark4life Mar 31, 2026
40eef2d
Address review feedback
aclark4life Mar 31, 2026
0234ecb
Refactor tests
aclark4life Apr 1, 2026
66d97ac
Bump zizmorcore/zizmor-action from 0.5.0 to 0.5.2 in the actions grou…
dependabot[bot] Mar 18, 2026
2a685fb
Address review feedback
aclark4life Apr 1, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
__pycache__
uv.lock
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,99 @@
# django-mongodb-extensions

Extensions for Django MongoDB Backend

## Extensions

### MQL Panel for Django Debug Toolbar

The first extension is the **MQL Panel** for
[django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar).
This panel provides detailed insights into MongoDB queries executed during a
request, similar to how the SQL panel works for relational databases.

**Features:**
- View all MongoDB queries (MQL) executed during a request
- See query execution time and identify slow queries
- Re-execute read operations (find, aggregate, etc.) directly from the toolbar
- Explain query execution plans
- Color-coded query grouping for easy identification
- Detailed query statistics and performance metrics

## Installation

### Requirements

- [django-mongodb-backend](https://github.com/mongodb-labs/django-mongodb-backend)
- [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar)

### Install Django Debug Toolbar

- If you haven't already, install django-debug-toolbar by following their
[installation instructions](https://django-debug-toolbar.readthedocs.io/en/latest/installation.html).

### Install the Package

```bash
pip install django-mongodb-extensions
Comment thread
timgraham marked this conversation as resolved.
Outdated
```

### Configure the MQL Panel

1. **Add to `INSTALLED_APPS`** in your Django settings:

```python
INSTALLED_APPS = [
# ...
'debug_toolbar',
'django_mongodb_extensions',
# ...
]
```

2. **Add the MQL Panel** to your debug toolbar configuration:

```python
DEBUG_TOOLBAR_PANELS = [
'debug_toolbar.panels.history.HistoryPanel',
'debug_toolbar.panels.versions.VersionsPanel',
'debug_toolbar.panels.timer.TimerPanel',
'debug_toolbar.panels.settings.SettingsPanel',
'debug_toolbar.panels.headers.HeadersPanel',
'debug_toolbar.panels.request.RequestPanel',
# Add this
'django_mongodb_extensions.debug_toolbar.panels.MQLPanel',
'debug_toolbar.panels.templates.TemplatesPanel',
'debug_toolbar.panels.staticfiles.StaticFilesPanel',
'debug_toolbar.panels.cache.CachePanel',
'debug_toolbar.panels.signals.SignalsPanel',
'debug_toolbar.panels.redirects.RedirectsPanel',
'debug_toolbar.panels.profiling.ProfilingPanel',
]
```

3. **Optional:** Configure parameters.
Comment thread
timgraham marked this conversation as resolved.
Outdated

```python
# Maximum number of documents to return when re-executing select
# queries (default is 100).
DJDT_MQL_MAX_SELECT_RESULTS = 25

# Queries slower than this threshold (in milliseconds) are highlighted
# in the debug toolbar (default is 500 ms).
DEFAULT_MQL_WARNING_THRESHOLD = 1000
```

### Usage

Once installed and configured, the MQL Panel will automatically appear in
your Django Debug Toolbar. It will display:

- **Query list**: All MongoDB operations executed during the request
- **Execution time**: Time taken for each query
- **Query details**: Collection name, operation type, and arguments
- **Explain button**: Click to see the query execution plan
- **Select button**: Re-execute read operations to see results

## License

See [LICENSE](LICENSE) file for details.
243 changes: 243 additions & 0 deletions django_mongodb_extensions/debug_toolbar/panels/mql/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
from bson import json_util
from django import forms
from django.core.exceptions import ValidationError
from django.db import connections
from django.utils.translation import gettext_lazy as _
from pymongo import errors as pymongo_errors
from debug_toolbar.panels.sql.forms import SQLSelectForm
from debug_toolbar.toolbar import DebugToolbar
from django_mongodb_extensions.debug_toolbar.panels.mql.utils import (
MQL_PANEL_ID,
QueryParts,
get_max_select_results,
parse_query_args,
)
import json
Comment thread
timgraham marked this conversation as resolved.
Outdated


class MQLBaseForm(SQLSelectForm):
"""Shared validation and helpers."""

def clean(self):
# Explicitly call forms.Form.clean() to bypass SQLSelectForm.clean()
# which has SQL-specific validation not needed for MQL queries.
cleaned_data = forms.Form.clean(self)
request_id = cleaned_data.get("request_id")
djdt_query_id = cleaned_data.get("djdt_query_id")
if not request_id:
raise ValidationError(_("Missing request ID."))
if not djdt_query_id:
raise ValidationError(_("Missing query ID."))
toolbar = DebugToolbar.fetch(request_id, panel_id=MQL_PANEL_ID)
if toolbar is None:
raise ValidationError(_("Data for this panel isn't available anymore."))
panel = toolbar.get_panel_by_id(MQL_PANEL_ID)
stats = panel.get_stats()
if not stats or "queries" not in stats:
raise ValidationError(_("Query data is not available."))
query = next(
(
_query
for _query in stats["queries"]
if isinstance(_query, dict)
and _query.get("djdt_query_id") == djdt_query_id
),
None,
)
if not query:
raise ValidationError(_("Invalid query ID."))
if not all(key in query for key in ["alias", "mql"]):
raise ValidationError(_("Query data is incomplete."))
cleaned_data["query"] = query
return cleaned_data

def _get_query_parts(self):
query_dict = self.cleaned_data["query"]
alias = query_dict.get("alias", "default")
connection = connections[alias]
collection_name, operation, args_list = parse_query_args(query_dict)
db = connection.database
return QueryParts(
query_dict=self.cleaned_data["query"],
alias=alias,
mql_string=query_dict.get("mql", ""),
connection=connections[alias],
db=db,
collection=db[collection_name],
collection_name=collection_name,
operation=operation,
args_list=args_list,
)

def _handle_operation_error(self, error, mql_string, operation_type="operation"):
error_map = {
pymongo_errors.OperationFailure: (
"MongoDB Operation Error",
[
f"MongoDB operation failed: {error}",
"The query syntax may be invalid or the operation is not supported.",
],
),
(
pymongo_errors.ConnectionFailure,
pymongo_errors.ServerSelectionTimeoutError,
): (
"MongoDB Connection Error",
[
f"MongoDB connection error: {error}",
"Could not connect to MongoDB server.",
"Check your database connection settings.",
],
),
pymongo_errors.PyMongoError: (
"MongoDB Error",
[
f"MongoDB error: {error}",
"An error occurred while executing the MongoDB operation.",
],
),
}
header, messages = None, []
for err_type, (_header, _messages) in error_map.items():
if isinstance(error, err_type):
header, messages = _header, _messages.copy()
break
if not header:
if isinstance(error, ValueError):
header = "Query Parsing Error"
messages = [f"Query parsing error: {error}"]
if operation_type == "select":
messages += [
"The MQL panel can only re-execute read operations.",
"Write operations (insert, update, delete) cannot be re-executed.",
]
else:
messages += [
"The MQL panel tracks raw MongoDB operations.",
"Some operations may not be re-executable from the debug toolbar.",
]
else:
header = f"{operation_type.capitalize()} Error"
messages = [
f"Unexpected error executing {operation_type}: {error}",
"An unexpected error occurred.",
]
body_text = "\n\n".join(messages)
formatted_body = body_text.split("\n")
formatted_body.extend(["", f"Original query: {mql_string}"])
# Return error in the same format as convert_documents_to_table():
# a one-row table with {value, is_json} cells
error_message = "\n".join(formatted_body)
rows = [[{"value": error_message, "is_json": False}]]
headers = [header]
return rows, headers

def _execute_operation(self, operation_type, executor_func):
mql_string = ""
try:
parts = self._get_query_parts()
mql_string = parts.mql_string
return executor_func(
parts.db,
parts.collection,
parts.collection_name,
parts.operation,
parts.args_list,
)
except Exception as e:
Comment thread
timgraham marked this conversation as resolved.
Outdated
return self._handle_operation_error(e, mql_string, operation_type)


class MQLExplainForm(MQLBaseForm):
def _execute_aggregate(self, db, collection_name, args_list):
pipeline = args_list[0] if args_list else []
return db.command(
"explain",
{"aggregate": collection_name, "pipeline": pipeline, "cursor": {}},
)

def _execute_explain(self, db, collection, collection_name, operation, args_list):
if operation == "aggregate":
explain_result = self._execute_aggregate(db, collection_name, args_list)
else:
raise ValueError(f"Unsupported operation: {operation}")
explain_json = json_util.dumps(explain_result, indent=4)
result = [[explain_json]]
headers = ["MongoDB Explain Output (JSON)"]
return result, headers

def explain(self):
return self._execute_operation("explain", self._execute_explain)


class MQLSelectForm(MQLBaseForm):
def _execute_aggregate(self, collection, args_list):
pipeline = args_list[0] if args_list else []
result_docs = []
max_results = get_max_select_results()
with collection.aggregate(pipeline) as cursor:
for i, doc in enumerate(cursor):
if i >= max_results:
break
result_docs.append(doc)
return result_docs

def _execute_select(self, db, collection, collection_name, operation, args_list):
if operation == "aggregate":
result_docs = self._execute_aggregate(collection, args_list)
else:
raise ValueError(f"Unsupported read operation: {operation}")
# Convert documents to table format with columns
return self.convert_documents_to_table(result_docs)

def select(self):
return self._execute_operation("select", self._execute_select)

def _format_cell_value(self, value):
"""Format a single cell value for table display.

Returns a dict with 'value' (str) and 'is_json' (bool) keys.
"""
if value is None:
return {"value": "", "is_json": False}
# Handle primitive types directly without JSON serialization
if isinstance(value, (str, int, float, bool)):
return {"value": str(value), "is_json": False}
# For complex types (ObjectId, datetime, dicts, lists, etc.), use json_util
try:
serialized = json_util.dumps(value)
parsed = json.loads(serialized)
# Extract value from single-key BSON extended JSON objects like {"$oid": "..."}
if isinstance(parsed, dict) and len(parsed) == 1:
key, val = next(iter(parsed.items()))
return {"value": str(val), "is_json": False}
# For multi-key objects, format with indentation for readability
if isinstance(parsed, dict) and len(parsed) > 1:
return {"value": json.dumps(parsed, indent=4), "is_json": True}
# For lists and other types, use compact serialization
return {"value": serialized, "is_json": False}
except (json.JSONDecodeError, TypeError, AttributeError):
# Fallback: convert to string
return {"value": str(value), "is_json": False}

def convert_documents_to_table(self, documents):
"""Convert MongoDB documents to table format with columns. Used in the debug
toolbar to display query results.
"""
if not documents:
return [], []
# Collect all unique field names
all_fields = set()
for doc in documents:
all_fields.update(doc.keys())
# Sort fields for consistent column ordering, with _id first if present
headers = sorted(all_fields)
if "_id" in headers:
headers.remove("_id")
headers.insert(0, "_id")
# Convert each document to a row with formatted values
rows = []
for doc in documents:
row = [self._format_cell_value(doc.get(field)) for field in headers]
rows.append(row)
return rows, headers
Loading
Loading