diff --git a/docs/reference/pages/queryset_reference.rst b/docs/reference/pages/queryset_reference.rst index 928abefa18..4e7a1ff590 100644 --- a/docs/reference/pages/queryset_reference.rst +++ b/docs/reference/pages/queryset_reference.rst @@ -270,4 +270,18 @@ Reference See also: :py:attr:`Page.specific ` + .. automethod:: defer_streamfields + + Example: + + .. code-block:: python + + # Apply to a queryset to avoid fetching StreamField values + # for a specific model + EventPage.objects.all().defer_streamfields() + + # Or combine with specific() to avoid fetching StreamField + # values for all models + homepage.get_children().defer_streamfields().specific() + .. automethod:: first_common_ancestor diff --git a/wagtail/core/models.py b/wagtail/core/models.py index 529d9d8e7f..5e962b35af 100644 --- a/wagtail/core/models.py +++ b/wagtail/core/models.py @@ -1,3 +1,4 @@ +import functools import json import logging import uuid @@ -40,6 +41,7 @@ from modelcluster.fields import ParentalKey, ParentalManyToManyField from modelcluster.models import ClusterableModel, get_all_child_relations from treebeard.mp_tree import MP_Node +from wagtail.core.fields import StreamField from wagtail.core.forms import TaskStateCommentForm from wagtail.core.query import PageQuerySet, TreeQuerySet from wagtail.core.signals import ( @@ -671,6 +673,14 @@ def get_default_page_content_type(): return ContentType.objects.get_for_model(Page) +@functools.lru_cache(maxsize=None) +def get_streamfield_names(model_class): + return tuple( + field.name for field in model_class._meta.concrete_fields + if isinstance(field, StreamField) + ) + + class BasePageManager(models.Manager): def get_queryset(self): return self._queryset_class(self.model).order_by('path') @@ -893,6 +903,10 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase): def __str__(self): return self.title + @classmethod + def get_streamfield_names(cls): + return get_streamfield_names(cls) + def set_url_path(self, parent): """ Populate the url_path field based on this page's slug and the specified parent page. diff --git a/wagtail/core/query.py b/wagtail/core/query.py index 924fe489eb..7a51c0d8e0 100644 --- a/wagtail/core/query.py +++ b/wagtail/core/query.py @@ -135,6 +135,18 @@ class TreeQuerySet(MP_NodeQuerySet): class PageQuerySet(SearchableQuerySetMixin, TreeQuerySet): + def __init__(self, *args, **kwargs): + """Set custom instance attributes""" + super().__init__(*args, **kwargs) + # set by defer_streamfields() + self._defer_streamfields = False + + def _clone(self): + """Ensure clones inherit custom attribute values.""" + clone = super()._clone() + clone._defer_streamfields = self._defer_streamfields + return clone + def live_q(self): return Q(live=True) @@ -345,6 +357,20 @@ class PageQuerySet(SearchableQuerySetMixin, TreeQuerySet): for page in self.live(): page.unpublish() + def defer_streamfields(self): + """ + Apply to a queryset to prevent fetching/decoding of StreamField values on + evaluation. Useful when working with potentially large numbers of results, + where StreamField values are unlikely to be needed. For example, when + generating a sitemap or a long list of page links. + """ + clone = self._clone() + clone._defer_streamfields = True # used by specific_iterator() + streamfield_names = self.model.get_streamfield_names() + if not streamfield_names: + return clone + return clone.defer(*streamfield_names) + def specific(self, defer=False): """ This efficiently gets all the specific pages for the queryset, using @@ -434,6 +460,8 @@ def specific_iterator(qs, defer=False): # Defer all specific fields fields = [field.attname for field in Page._meta.get_fields() if field.concrete] pages = pages.only(*fields) + elif qs._defer_streamfields: + pages = pages.defer_streamfields() pages_for_type = {page.pk: page for page in pages} pages_by_type[content_type] = pages_for_type diff --git a/wagtail/core/tests/test_page_queryset.py b/wagtail/core/tests/test_page_queryset.py index b5c192171c..9084a4f7c7 100644 --- a/wagtail/core/tests/test_page_queryset.py +++ b/wagtail/core/tests/test_page_queryset.py @@ -871,10 +871,19 @@ class TestFirstCommonAncestor(TestCase): fixtures = ['test_specific.json'] def setUp(self): + self.root_page = Page.objects.get(url_path='/home/') self.all_events = Page.objects.type(EventPage) self.regular_events = Page.objects.type(EventPage)\ .exclude(url_path__contains='/other/') + def _create_streampage(self): + stream_page = StreamPage( + title='stream page', + slug='stream-page', + body='[{"type": "text", "value": "foo"}]', + ) + self.root_page.add_child(instance=stream_page) + def test_bookkeeping(self): self.assertEqual(self.all_events.count(), 4) self.assertEqual(self.regular_events.count(), 3) @@ -936,3 +945,17 @@ class TestFirstCommonAncestor(TestCase): def test_empty_queryset_strict(self): with self.assertRaises(Page.DoesNotExist): Page.objects.none().first_common_ancestor(strict=True) + + def test_defer_streamfields_without_specific(self): + self._create_streampage() + for page in StreamPage.objects.all().defer_streamfields(): + self.assertNotIn('body', page.__dict__) + with self.assertNumQueries(1): + page.body + + def test_defer_streamfields_with_specific(self): + self._create_streampage() + for page in Page.objects.exact_type(StreamPage).defer_streamfields().specific(): + self.assertNotIn('body', page.__dict__) + with self.assertNumQueries(1): + page.body