Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
105 changes: 103 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,104 @@
# django-mongodb-extensions
# Django MongoDB Extensions

Extensions for Django MongoDB Backend
A collection of extensions for Django when using MongoDB, inspired by
[django-extensions](https://github.com/django-extensions/django-extensions).
Comment thread
timgraham marked this conversation as resolved.
Outdated

## 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.

> [!TIP]
> This library does not require django-debug-toolbar, but you will need it to use the MQL Panel extension.
Comment thread
timgraham marked this conversation as resolved.
Outdated

**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 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 maximum select results** (default is 100):

```python
# Maximum number of documents to return when re-executing select
# queries
DJDT_MQL_MAX_SELECT_RESULTS = 25
```

### 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

## Development

### Running Tests

To run tests with [uv](https://docs.astral.sh/uv/), use the following command:

```bash
uv run --extra test --with django-mongodb-backend django-admin test --settings=tests.settings
```
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better to make test bootstrap a separate PR to precede this one. This doesn't look so nice to me compared to runtests.py convention of Django / Django MongoDB. The uv ... incantation is a lot to type or lookup and copy/paste each time.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what just test is for but I'm not opposed to using runtests.py.


## License

See [LICENSE](LICENSE) file for details.
241 changes: 241 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,241 @@
"""Forms for MQL panel."""
Comment thread
timgraham marked this conversation as resolved.
Outdated

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,
convert_documents_to_table,
get_max_select_results,
parse_query_args,
)


class MQLBaseForm(SQLSelectForm):
"""Base form with shared validation and helpers."""

def clean(self):
# Explicitly call forms.Form.clean() to bypass SQLSelectForm.clean()
# which has SQL-specific validation we don't need for MQL queries.
Comment thread
timgraham marked this conversation as resolved.
Outdated
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(
(
q
for q in stats["queries"]
if isinstance(q, dict) and q.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")
mql_string = query_dict.get("mql", "")
connection = connections[alias]
collection_name, operation, args_list = parse_query_args(query_dict)
db = connection.database
collection = db[collection_name]

Comment thread
timgraham marked this conversation as resolved.
Outdated
return QueryParts(
query_dict=query_dict,
alias=alias,
mql_string=mql_string,
connection=connection,
db=db,
collection=collection,
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, (h, m) in error_map.items():
Comment thread
timgraham marked this conversation as resolved.
Outdated
if isinstance(error, err_type):
header, messages = h, m.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 [formatted_body], [header]
Comment thread
aclark4life marked this conversation as resolved.
Outdated

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_find(self, collection, args_list):
if len(args_list) >= 2:
filter_doc, projection = args_list[0], args_list[1]
cursor = collection.find(filter_doc, projection)
elif len(args_list) == 1:
filter_doc = args_list[0]
cursor = collection.find(filter_doc)
else:
cursor = collection.find({})
try:
return cursor.explain()
finally:
cursor.close()

def _execute_explain(self, db, collection, collection_name, operation, args_list):
if operation == "aggregate":
explain_result = self._execute_aggregate(db, collection_name, args_list)
elif operation == "find":
explain_result = self._execute_find(collection, 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_find(self, collection, args_list):
max_results = get_max_select_results()
if len(args_list) >= 2:
filter_doc, projection = args_list[0], args_list[1]
cursor = collection.find(filter_doc, projection)
elif len(args_list) == 1:
filter_doc = args_list[0]
cursor = collection.find(filter_doc)
else:
cursor = collection.find({})
try:
return list(cursor.limit(max_results))
finally:
cursor.close()

def _execute_select(self, db, collection, collection_name, operation, args_list):
if operation == "aggregate":
result_docs = self._execute_aggregate(collection, args_list)
elif operation == "find":
result_docs = self._execute_find(collection, args_list)
else:
raise ValueError(f"Unsupported read operation: {operation}")

# Convert documents to table format with columns
return convert_documents_to_table(result_docs)

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