Skip to content

Commit 36c5718

Browse files
INTPYTHON-348 add support for QuerySet.raw_aggregate() (#183)
* add intersphinx configuration to docs * INTPYTHON-348 add QuerySet.raw_aggregate() Co-authored-by: Tim Graham <[email protected]> --------- Co-authored-by: Tim Graham <[email protected]>
1 parent ca8ac6a commit 36c5718

File tree

9 files changed

+562
-10
lines changed

9 files changed

+562
-10
lines changed

.github/workflows/runtests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
"queries",
121121
"queries_",
122122
"queryset_pickle",
123+
"raw_query_",
123124
"redirects_tests",
124125
"reserved_names",
125126
"reverse_lookup",

django_mongodb/managers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.db.models.manager import BaseManager
2+
3+
from .queryset import MongoQuerySet
4+
5+
6+
class MongoManager(BaseManager.from_queryset(MongoQuerySet)):
7+
pass

django_mongodb/queryset.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from itertools import chain
2+
3+
from django.core.exceptions import FieldDoesNotExist
4+
from django.db import connections
5+
from django.db.models import QuerySet
6+
from django.db.models.query import RawModelIterable as BaseRawModelIterable
7+
from django.db.models.query import RawQuerySet as BaseRawQuerySet
8+
from django.db.models.sql.query import RawQuery as BaseRawQuery
9+
10+
11+
class MongoQuerySet(QuerySet):
12+
def raw_aggregate(self, pipeline, using=None):
13+
return RawQuerySet(pipeline, model=self.model, using=using)
14+
15+
16+
class RawQuerySet(BaseRawQuerySet):
17+
def __init__(self, pipeline, model=None, using=None):
18+
super().__init__(pipeline, model=model, using=using)
19+
self.query = RawQuery(pipeline, using=self.db, model=self.model)
20+
# Override the superclass's columns property which relies on PEP 249's
21+
# cursor.description. Instead, RawModelIterable will set the columns
22+
# based on the keys in the first result.
23+
self.columns = None
24+
25+
def iterator(self):
26+
yield from RawModelIterable(self)
27+
28+
29+
class RawQuery(BaseRawQuery):
30+
def __init__(self, pipeline, using, model):
31+
self.pipeline = pipeline
32+
super().__init__(sql=None, using=using)
33+
self.model = model
34+
35+
def _execute_query(self):
36+
connection = connections[self.using]
37+
collection = connection.get_collection(self.model._meta.db_table)
38+
self.cursor = collection.aggregate(self.pipeline)
39+
40+
def __str__(self):
41+
return str(self.pipeline)
42+
43+
44+
class RawModelIterable(BaseRawModelIterable):
45+
def __iter__(self):
46+
"""
47+
This is copied from the superclass except for the part that sets
48+
self.queryset.columns from the first result.
49+
"""
50+
db = self.queryset.db
51+
query = self.queryset.query
52+
connection = connections[db]
53+
compiler = connection.ops.compiler("SQLCompiler")(query, connection, db)
54+
query_iterator = iter(query)
55+
try:
56+
# Get the columns from the first result.
57+
try:
58+
first_result = next(query_iterator)
59+
except StopIteration:
60+
# No results.
61+
return
62+
self.queryset.columns = list(first_result.keys())
63+
# Reset the iterator to include the first item.
64+
query_iterator = self._make_result(chain([first_result], query_iterator))
65+
(
66+
model_init_names,
67+
model_init_pos,
68+
annotation_fields,
69+
) = self.queryset.resolve_model_init_order()
70+
model_cls = self.queryset.model
71+
if model_cls._meta.pk.attname not in model_init_names:
72+
raise FieldDoesNotExist("Raw query must include the primary key")
73+
fields = [self.queryset.model_fields.get(c) for c in self.queryset.columns]
74+
converters = compiler.get_converters(
75+
[f.get_col(f.model._meta.db_table) if f else None for f in fields]
76+
)
77+
if converters:
78+
query_iterator = compiler.apply_converters(query_iterator, converters)
79+
for values in query_iterator:
80+
# Associate fields to values
81+
model_init_values = [values[pos] for pos in model_init_pos]
82+
instance = model_cls.from_db(db, model_init_names, model_init_values)
83+
if annotation_fields:
84+
for column, pos in annotation_fields:
85+
setattr(instance, column, values[pos])
86+
yield instance
87+
finally:
88+
query.cursor.close()
89+
90+
def _make_result(self, query):
91+
"""
92+
Convert documents (dictionaries) to tuples as expected by the rest
93+
of __iter__().
94+
"""
95+
for result in query:
96+
yield tuple(result.values())

docs/source/conf.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,25 @@
1717
# -- General configuration ---------------------------------------------------
1818
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
1919

20-
extensions = []
20+
# If true, the current module name will be prepended to all description
21+
# unit titles (such as .. function::).
22+
add_module_names = False
23+
24+
extensions = [
25+
"sphinx.ext.intersphinx",
26+
]
2127

2228
# templates_path = ["_templates"]
2329
exclude_patterns = []
2430

31+
intersphinx_mapping = {
32+
"django": (
33+
"https://docs.djangoproject.com/en/5.0/",
34+
"http://docs.djangoproject.com/en/5.0/_objects/",
35+
),
36+
"pymongo": ("https://pymongo.readthedocs.io/en/stable/", None),
37+
"python": ("https://docs.python.org/3/", None),
38+
}
2539

2640
# -- Options for HTML output -------------------------------------------------
2741
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

docs/source/index.rst

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
.. django_mongodb documentation master file, created by
2-
sphinx-quickstart on Mon Apr 15 12:38:26 2024.
3-
You can adapt this file completely to your liking, but it should at least
4-
contain the root ``toctree`` directive.
5-
6-
Welcome to django_mongodb's documentation!
7-
==========================================
1+
django-mongodb 5.0.x documentation
2+
==================================
83

94
.. toctree::
10-
:maxdepth: 2
5+
:maxdepth: 1
116
:caption: Contents:
127

13-
8+
querysets
149

1510
Indices and tables
1611
==================

docs/source/querysets.rst

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
``QuerySet`` API reference
2+
==========================
3+
4+
Some MongoDB-specific ``QuerySet`` methods are available by adding a custom
5+
:class:`~django.db.models.Manager`, ``MongoManager``, to your model::
6+
7+
from django.db import models
8+
9+
from django_mongodb.managers import MongoManager
10+
11+
12+
class MyModel(models.Model):
13+
...
14+
15+
objects = MongoManager()
16+
17+
18+
.. currentmodule:: django_mongodb.queryset.MongoQuerySet
19+
20+
``raw_aggregate()``
21+
-------------------
22+
23+
.. method:: raw_aggregate(pipeline, using=None)
24+
25+
Similar to :meth:`QuerySet.raw()<django.db.models.query.QuerySet.raw>`, but
26+
instead of a raw SQL query, this method accepts a pipeline that will be passed
27+
to :meth:`pymongo.collection.Collection.aggregate`.
28+
29+
For example, you could write a custom match criteria::
30+
31+
Question.objects.raw_aggregate([{"$match": {"question_text": "What's up"}}])
32+
33+
The pipeline may also return additional fields that will be added as
34+
annotations on the models::
35+
36+
>>> questions = Question.objects.raw_aggregate([{
37+
... "$project": {
38+
... "question_text": 1,
39+
... "pub_date": 1,
40+
... "year_published": {"$year": "$pub_date"}
41+
... }
42+
... }])
43+
>>> for q in questions:
44+
... print(f"{q.question_text} was published in {q.year_published}.")
45+
...
46+
What's up? was published in 2024.
47+
48+
Fields may also be left out:
49+
50+
>>> Question.objects.raw_aggregate([{"$project": {"question_text": 1}}])
51+
52+
The ``Question`` objects returned by this query will be deferred model instances
53+
(see :meth:`~django.db.models.query.QuerySet.defer()`). This means that the
54+
fields that are omitted from the query will be loaded on demand. For example::
55+
56+
>>> for q in Question.objects.raw_aggregate([{"$project": {"question_text": 1}}]):
57+
>>> print(
58+
... q.question_text, # This will be retrieved by the original query.
59+
... q.pub_date, # This will be retrieved on demand.
60+
... )
61+
...
62+
What's new 2023-09-03 12:00:00+00:00
63+
What's up 2024-08-23 20:57:30+00:00
64+
65+
From outward appearances, this looks like the query has retrieved both the
66+
question text and published date. However, this example actually issued three
67+
queries. Only the question texts were retrieved by the ``raw_aggregate()``
68+
query -- the published dates were both retrieved on demand when they were
69+
printed.

tests/raw_query_/__init__.py

Whitespace-only changes.

tests/raw_query_/models.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from django.db import models
2+
3+
from django_mongodb.fields import ObjectIdAutoField
4+
from django_mongodb.managers import MongoManager
5+
6+
7+
class Author(models.Model):
8+
first_name = models.CharField(max_length=255)
9+
last_name = models.CharField(max_length=255)
10+
dob = models.DateField()
11+
12+
objects = MongoManager()
13+
14+
def __init__(self, *args, **kwargs):
15+
super().__init__(*args, **kwargs)
16+
# Protect against annotations being passed to __init__ --
17+
# this'll make the test suite get angry if annotations aren't
18+
# treated differently than fields.
19+
for k in kwargs:
20+
assert k in [f.attname for f in self._meta.fields], (
21+
"Author.__init__ got an unexpected parameter: %s" % k
22+
)
23+
24+
25+
class Book(models.Model):
26+
title = models.CharField(max_length=255)
27+
author = models.ForeignKey(Author, models.CASCADE)
28+
paperback = models.BooleanField(default=False)
29+
opening_line = models.TextField()
30+
31+
objects = MongoManager()
32+
33+
34+
class BookFkAsPk(models.Model):
35+
book = models.ForeignKey(Book, models.CASCADE, primary_key=True, db_column="not_the_default")
36+
37+
objects = MongoManager()
38+
39+
40+
class Coffee(models.Model):
41+
brand = models.CharField(max_length=255, db_column="name")
42+
price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
43+
44+
objects = MongoManager()
45+
46+
47+
class MixedCaseIDColumn(models.Model):
48+
id = ObjectIdAutoField(primary_key=True, db_column="MiXeD_CaSe_Id")
49+
50+
objects = MongoManager()
51+
52+
53+
class Reviewer(models.Model):
54+
reviewed = models.ManyToManyField(Book)
55+
56+
objects = MongoManager()
57+
58+
59+
class FriendlyAuthor(Author):
60+
pass

0 commit comments

Comments
 (0)