-
Notifications
You must be signed in to change notification settings - Fork 3
INTPYTHON-423 MQL panel for Django Debug Toolbar #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
a88b308
8328eda
a7c9ec7
4bade48
86b57e1
8423a65
8885aa4
22ba13d
352bf3b
4de35fe
8186c5a
a60193e
fc8cc25
b44209c
246f537
1f27725
4777e75
b58f94c
7c8508e
0a8e660
a6c71fd
63bbdcd
48c9058
043fdf1
f6b9dba
5b6a8e5
fa11ab2
5c3e9b7
048534f
783a966
5b758c1
7bc42a9
40eef2d
0234ecb
66d97ac
2a685fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| __pycache__ | ||
| uv.lock |
| 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). | ||
|
|
||
| ## 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. | ||
|
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 | ||
|
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 | ||
| ``` | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's what |
||
|
|
||
| ## License | ||
|
|
||
| See [LICENSE](LICENSE) file for details. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,241 @@ | ||
| """Forms for MQL panel.""" | ||
|
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. | ||
|
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] | ||
|
|
||
|
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(): | ||
|
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] | ||
|
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: | ||
|
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) | ||
Uh oh!
There was an error while loading. Please reload this page.