From ced5649a2ce8a568c426596f3133099823162375 Mon Sep 17 00:00:00 2001 From: Jib Date: Wed, 16 Jul 2025 13:45:33 -0400 Subject: [PATCH] Make QuerySet.explain() return parsable JSON Co-authored-by: Tim Graham --- django_mongodb_backend/compiler.py | 10 ++------ docs/source/ref/models/querysets.rst | 20 +++++++++++++++ docs/source/releases/5.2.x.rst | 2 ++ tests/queries_/test_explain.py | 37 ++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 tests/queries_/test_explain.py diff --git a/django_mongodb_backend/compiler.py b/django_mongodb_backend/compiler.py index 1c727039d..3ef6a1ea7 100644 --- a/django_mongodb_backend/compiler.py +++ b/django_mongodb_backend/compiler.py @@ -1,8 +1,7 @@ import itertools -import pprint from collections import defaultdict -from bson import SON +from bson import SON, json_util from django.core.exceptions import EmptyResultSet, FieldError, FullResultSet from django.db import IntegrityError, NotSupportedError from django.db.models import Count @@ -652,12 +651,7 @@ def explain_query(self): {"aggregate": self.collection_name, "pipeline": pipeline, "cursor": {}}, **kwargs, ) - # Generate the output: a list of lines that Django joins with newlines. - result = [] - for key, value in explain.items(): - formatted_value = pprint.pformat(value, indent=4) - result.append(f"{key}: {formatted_value}") - return result + return [json_util.dumps(explain, indent=4, ensure_ascii=False)] class SQLInsertCompiler(SQLCompiler): diff --git a/docs/source/ref/models/querysets.rst b/docs/source/ref/models/querysets.rst index c789d24d3..290a79cb2 100644 --- a/docs/source/ref/models/querysets.rst +++ b/docs/source/ref/models/querysets.rst @@ -15,6 +15,8 @@ In addition, :meth:`QuerySet.delete() ` and :meth:`update() ` do not support queries that span multiple collections. +.. _queryset-explain: + ``QuerySet.explain()`` ====================== @@ -29,6 +31,24 @@ Example:: Valid values for ``verbosity`` are ``"queryPlanner"`` (default), ``"executionStats"``, and ``"allPlansExecution"``. +The result of ``explain()`` is a string:: + + >>> print(Model.objects.explain()) + { + "explainVersion": "1", + "queryPlanner": { + ... + }, + ... + } + +that can be parsed as JSON:: + + >>> from bson import json_util + >>> result = Model.objects.filter(name="MongoDB").explain() + >>> json_util.loads(result)['command']["pipeline"] + [{'$match': {'$expr': {'$eq': ['$name', 'MongoDB']}}}] + MongoDB-specific ``QuerySet`` methods ===================================== diff --git a/docs/source/releases/5.2.x.rst b/docs/source/releases/5.2.x.rst index f0f09ab95..e1fdf0332 100644 --- a/docs/source/releases/5.2.x.rst +++ b/docs/source/releases/5.2.x.rst @@ -22,6 +22,8 @@ Bug fixes - Fixed ``RecursionError`` when using ``Trunc`` database functions on non-MongoDB databases. +- :meth:`QuerySet.explain() ` now + :ref:`returns a string that can be parsed as JSON `. 5.2.0 beta 1 ============ diff --git a/tests/queries_/test_explain.py b/tests/queries_/test_explain.py new file mode 100644 index 000000000..d0e964150 --- /dev/null +++ b/tests/queries_/test_explain.py @@ -0,0 +1,37 @@ +import json + +from bson import ObjectId, json_util +from django.test import TestCase + +from .models import Author + + +class ExplainTests(TestCase): + def test_idented(self): + """The JSON is dumped with indent=4.""" + result = Author.objects.filter().explain() + self.assertEqual(result[:23], '{\n "explainVersion":') + + def test_object_id(self): + """ + The json is dumped with bson.json_util() so that BSON types like ObjectID are + specially encoded. + """ + id = ObjectId() + result = Author.objects.filter(id=id).explain() + parsed = json_util.loads(result) + self.assertEqual( + parsed["command"]["pipeline"], [{"$match": {"$expr": {"$eq": ["$_id", id]}}}] + ) + + def test_non_ascii(self): + """The json is dumped with ensure_ascii=False.""" + name = "\U0001d120" + result = Author.objects.filter(name=name).explain() + # The non-decoded string must be checked since json.loads() unescapes + # non-ASCII characters. + self.assertIn(name, result) + parsed = json.loads(result) + self.assertEqual( + parsed["command"]["pipeline"], [{"$match": {"$expr": {"$eq": ["$name", name]}}}] + )