Skip to content

Commit da573b2

Browse files
committed
fix: Improve performance by not always prefetching channels and target
This fixed performance for the common use case where people don't use Notification.target to dynamically generate the notification text. By always prefetching the `target` field, it was a performance penalty for everyone. Now you need to explicitly prefetch the target field yourself. See performance.md for more information. Always prefetching the channels relationship didn't make sense since users don't normally display these fields.
1 parent 51ad405 commit da573b2

File tree

4 files changed

+45
-22
lines changed

4 files changed

+45
-22
lines changed

docs/performance.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44

55
The `target` field is a GenericForeignKey that can point to any Django model instance. While convenient, accessing targets requires careful consideration for performance.
66

7-
When using Django 5.0+, this library automatically includes `.prefetch_related("target")` when using the standard query methods. This efficiently fetches target objects, but only the _direct_ targets - accessing relationships _through_ the target will still cause additional queries.
7+
By default, the library does **not** prefetch targets to avoid unnecessary queries when they're not needed. If you need to access the target field, you should explicitly prefetch it:
88

9-
_On Django 4.2, you'll need to manually deal with prefetching the `target` relationship._
9+
```python
10+
# Add target prefetch when needed
11+
notifications = get_notifications(user).prefetch_related("target")
12+
```
13+
14+
Note that this only fetches the _direct_ targets - accessing relationships _through_ the target will still cause additional queries.
1015

1116
Consider this problematic example that will cause N+1 queries:
1217

@@ -37,10 +42,10 @@ class CommentNotificationType(NotificationType):
3742
return f'{actor_name} commented on your article "{article.title}": "{comment_text}"'
3843
```
3944

40-
When displaying a list of 10 notifications, this will execute:
45+
When displaying a list of 10 notifications, with `.prefetch_related("target")` applied, this will execute:
4146

4247
- 1 query for the notifications
43-
- 1 query for the target comments (Django 5.0+ only, automatically prefetched)
48+
- 1 query for the target comments
4449
- 10 queries for the articles (N+1 problem!)
4550

4651
#### Solution 1: store data in the notification
@@ -59,15 +64,14 @@ send_notification(
5964
)
6065
```
6166

62-
However, this only works if you don’t need to dynamically generate the text - for example to make sure the text is always up to date, or to deal with internationalization.
67+
However, this only works if you don’t need to dynamically generate the text - for example to make sure the text is always up to date, or to deal with [internationalization](https://github.com/loopwerk/django-generic-notifications/blob/main/docs/multilingual.md).
6368

6469
#### Solution 2: prefetch deeper relationships
6570

6671
If you must access relationships through the target, you can prefetch them:
6772

6873
```python
69-
# On Django 5.0+ the library already prefetches targets,
70-
# but you need to add deeper relationships yourself
74+
# Prefetch the article relationship through target
7175
notifications = get_notifications(user).prefetch_related(
7276
"target__article" # This prevents the N+1 problem
7377
)

generic_notifications/models.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import django
21
from django.conf import settings
32
from django.contrib.auth import get_user_model
43
from django.contrib.contenttypes.fields import GenericForeignKey
@@ -18,13 +17,7 @@ class NotificationQuerySet(models.QuerySet):
1817

1918
def prefetch(self):
2019
"""Prefetch related objects"""
21-
qs = self.select_related("recipient", "actor").prefetch_related("channels")
22-
23-
# Only add target prefetching on Django 5.0+ due to GenericForeignKey limitations
24-
if django.VERSION >= (5, 0):
25-
qs = qs.prefetch_related("target")
26-
27-
return qs
20+
return self.select_related("recipient", "actor")
2821

2922
def for_channel(self, channel: type[BaseChannel] = WebsiteChannel):
3023
"""Filter notifications by channel"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-generic-notifications"
3-
version = "2.0.0"
3+
version = "2.0.1"
44
description = "A flexible, multi-channel notification system for Django applications with built-in support for email digests, user preferences, and extensible delivery channels."
55
authors = [
66
{name = "Kevin Renskers", email = "kevin@loopwerk.io"},

tests/test_performance.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def setUp(self):
2525

2626
def test_get_notifications_queries(self):
2727
"""Test the number of queries made by get_notifications"""
28-
with self.assertNumQueries(2): # 1 for notifications + 1 for channels prefetch
28+
with self.assertNumQueries(1):
2929
notifications = get_notifications(self.user)
3030
# Force evaluation of the queryset
3131
list(notifications)
@@ -71,8 +71,8 @@ def test_notification_target_access_queries(self):
7171
)
7272

7373
# First, evaluate the queryset
74-
with self.assertNumQueries(3): # 1 for notifications + 1 for channels prefetch + 1 for targets
75-
notifications = get_notifications(self.user)
74+
with self.assertNumQueries(2): # 1 for notifications + 1 for targets
75+
notifications = get_notifications(self.user).prefetch_related("target")
7676
notifications_list = list(notifications)
7777

7878
# Test accessing target - should be 0 queries since we prefetch target
@@ -104,8 +104,8 @@ def test_notification_target_relationship_access(self):
104104
)
105105

106106
# First, evaluate the queryset
107-
with self.assertNumQueries(3): # 1 for notifications + 1 for channels prefetch + 1 for targets
108-
notifications = get_notifications(self.user)
107+
with self.assertNumQueries(2): # 1 for notifications + 1 for targets
108+
notifications = get_notifications(self.user).prefetch_related("target")
109109
notifications_list = list(notifications)
110110

111111
# Test accessing target.recipient - this WILL cause N+1 queries
@@ -138,7 +138,7 @@ def test_notification_target_relationship_preselect_access(self):
138138
)
139139

140140
# First, evaluate the queryset
141-
with self.assertNumQueries(4): # 1 for notifications + 1 for channels + 1 for targets + 1 for target recipients
141+
with self.assertNumQueries(3): # 1 for notifications + 1 for targets + 1 for target recipients
142142
notifications = get_notifications(self.user).prefetch_related("target__recipient")
143143
notifications_list = list(notifications)
144144

@@ -148,3 +148,29 @@ def test_notification_target_relationship_preselect_access(self):
148148
for notification in notifications_list:
149149
if notification.target and hasattr(notification.target, "recipient"):
150150
_ = notification.target.recipient.email
151+
152+
def test_notification_mixed_targets_queries(self):
153+
"""Test queries with heterogeneous notification.target"""
154+
# Create notifications with targets
155+
notification = create_notification_with_channels(
156+
user=self.user,
157+
actor=self.actor,
158+
notification_type="test_notification",
159+
subject="Test notification 1",
160+
text="This is test notification 1",
161+
target=self.actor,
162+
)
163+
164+
create_notification_with_channels(
165+
user=self.user,
166+
actor=self.actor,
167+
notification_type="test_notification",
168+
subject="Test notification 2",
169+
text="This is test notification 2",
170+
target=notification,
171+
)
172+
173+
# First, evaluate the queryset
174+
with self.assertNumQueries(1):
175+
notifications = get_notifications(self.user)
176+
_ = list(notifications)

0 commit comments

Comments
 (0)