diff --git a/README.md b/README.md index c8d972b15..40a7d0bef 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,16 @@ And whenever you run `python manage.py startapp`, you must remove the line: from the new application's `apps.py` file. +## Notes on Django QuerySets + +* `QuerySet.explain()` supports the [`comment` and `verbosity` options]( + https://www.mongodb.com/docs/manual/reference/command/explain/#command-fields). + + Example: `QuerySet.explain(comment="...", verbosity="...")` + + Valid values for `verbosity` are `"queryPlanner"` (default), + `"executionStats"`, and `"allPlansExecution"`. + ## Known issues and limitations - The following `QuerySet` methods aren't supported: diff --git a/django_mongodb/compiler.py b/django_mongodb/compiler.py index a8fba8a8e..d84790bf4 100644 --- a/django_mongodb/compiler.py +++ b/django_mongodb/compiler.py @@ -1,4 +1,5 @@ import itertools +import pprint from collections import defaultdict from bson import SON @@ -491,6 +492,37 @@ def _get_ordering(self): def get_where(self): return self.where + def explain_query(self): + # Validate format (none supported) and options. + options = self.connection.ops.explain_query_prefix( + self.query.explain_info.format, + **self.query.explain_info.options, + ) + # Build the query pipeline. + self.pre_sql_setup() + columns = self.get_columns() + query = self.build_query( + # Avoid $project (columns=None) if unneeded. + columns if self.query.annotations or not self.query.default_cols else None + ) + pipeline = query.get_pipeline() + # Explain the pipeline. + kwargs = {} + for option in self.connection.ops.explain_options: + if value := options.get(option): + kwargs[option] = value + explain = self.connection.database.command( + "explain", + {"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 + class SQLInsertCompiler(SQLCompiler): def execute_sql(self, returning_fields=None): diff --git a/django_mongodb/features.py b/django_mongodb/features.py index 2fa66bf9a..41b64a5d9 100644 --- a/django_mongodb/features.py +++ b/django_mongodb/features.py @@ -8,6 +8,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): has_json_object_function = False has_native_json_field = True supports_date_lookup_using_string = False + supports_explaining_query_execution = True supports_foreign_keys = False supports_ignore_conflicts = False supports_json_field_contains = False @@ -53,9 +54,6 @@ class DatabaseFeatures(BaseDatabaseFeatures): "expressions.tests.IterableLookupInnerExpressionsTests.test_expressions_in_lookups_join_choice", # Unexpected alias_refcount in alias_map. "queries.tests.Queries1Tests.test_order_by_tables", - # QuerySet.explain() not implemented: - # https://github.com/mongodb-labs/django-mongodb/issues/28 - "queries.test_explain.ExplainUnsupportedTests.test_message", # The $sum aggregation returns 0 instead of None for null. "aggregation.test_filter_argument.FilteredAggregateTests.test_plain_annotate", "aggregation.tests.AggregateTestCase.test_aggregation_default_passed_another_aggregate", diff --git a/django_mongodb/operations.py b/django_mongodb/operations.py index 7c7be3f17..61d0b482c 100644 --- a/django_mongodb/operations.py +++ b/django_mongodb/operations.py @@ -26,6 +26,7 @@ class DatabaseOperations(BaseDatabaseOperations): Combinable.BITOR: "bitOr", Combinable.BITXOR: "bitXor", } + explain_options = {"comment", "verbosity"} def adapt_datefield_value(self, value): """Store DateField as datetime.""" @@ -198,6 +199,19 @@ def _prep_lookup_value(self, value, field, field_kind, lookup): value = self.adapt_decimalfield_value(value, field.max_digits, field.decimal_places) return value + def explain_query_prefix(self, format=None, **options): + # Validate options. + validated_options = {} + if options: + for valid_option in self.explain_options: + value = options.pop(valid_option, None) + if value is not None: + validated_options[valid_option] = value + # super() raises an error if any options are left after the valid ones + # are popped above. + super().explain_query_prefix(format, **options) + return validated_options + """Django uses these methods to generate SQL queries before it generates MQL queries.""" # EXTRACT format cannot be passed in parameters.