Skip to content

Commit ac3185a

Browse files
authored
Merge pull request #5530 from rtfd/davidfischer/optimize-footer-api
Optimize database performance of the footer API
2 parents 23acb0d + 7d8813f commit ac3185a

File tree

3 files changed

+95
-10
lines changed

3 files changed

+95
-10
lines changed

readthedocs/core/resolver.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""URL resolver for documentation."""
22

3+
import logging
34
from urllib.parse import urlunparse
45

56
from django.conf import settings
67

78
from readthedocs.core.utils.extend import SettingsOverrideObject
89
from readthedocs.projects.constants import PRIVATE, PUBLIC
910

11+
log = logging.getLogger(__name__)
12+
1013

1114
class ResolverBase:
1215

@@ -99,7 +102,7 @@ def resolve_path(
99102
private=None,
100103
):
101104
"""Resolve a URL with a subset of fields defined."""
102-
cname = cname or project.domains.filter(canonical=True).first()
105+
cname = cname or project.get_canonical_custom_domain()
103106
version_slug = version_slug or project.get_default_version()
104107
language = language or project.language
105108

@@ -115,7 +118,7 @@ def resolve_path(
115118
# translations, only loop twice to avoid sticking in the loop
116119
for _ in range(0, 2):
117120
main_language_project = current_project.main_language_project
118-
relation = current_project.superprojects.first()
121+
relation = current_project.get_parent_relationship()
119122

120123
if main_language_project:
121124
current_project = main_language_project
@@ -147,7 +150,7 @@ def resolve_path(
147150
def resolve_domain(self, project, private=None):
148151
# pylint: disable=unused-argument
149152
canonical_project = self._get_canonical_project(project)
150-
domain = self._get_project_custom_domain(canonical_project)
153+
domain = canonical_project.get_canonical_custom_domain()
151154
if domain:
152155
return domain.domain
153156

@@ -167,7 +170,7 @@ def resolve(
167170
private = self._get_private(project, version_slug)
168171

169172
canonical_project = self._get_canonical_project(project)
170-
custom_domain = self._get_project_custom_domain(canonical_project)
173+
custom_domain = canonical_project.get_canonical_custom_domain()
171174
use_custom_domain = self._use_custom_domain(custom_domain)
172175

173176
if use_custom_domain:
@@ -213,9 +216,9 @@ def _get_canonical_project(self, project, projects=None):
213216
projects = [project]
214217
else:
215218
projects.append(project)
216-
next_project = None
217219

218-
relation = project.superprojects.first()
220+
next_project = None
221+
relation = project.get_parent_relationship()
219222
if project.main_language_project:
220223
next_project = project.main_language_project
221224
elif relation:
@@ -232,9 +235,6 @@ def _get_project_subdomain(self, project):
232235
subdomain_slug = project.slug.replace('_', '-')
233236
return '{}.{}'.format(subdomain_slug, public_domain)
234237

235-
def _get_project_custom_domain(self, project):
236-
return project.domains.filter(canonical=True).first()
237-
238238
def _get_private(self, project, version_slug):
239239
from readthedocs.builds.models import Version
240240
try:

readthedocs/projects/models.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.contrib.auth.models import User
1111
from django.core.files.storage import get_storage_class
1212
from django.db import models
13+
from django.db.models import Prefetch
1314
from django.urls import NoReverseMatch, reverse
1415
from django.utils.translation import ugettext_lazy as _
1516
from django_extensions.db.models import TimeStampedModel
@@ -882,7 +883,21 @@ def ordered_active_versions(self, user=None):
882883
}
883884
if user:
884885
kwargs['user'] = user
885-
versions = Version.objects.public(**kwargs)
886+
versions = Version.objects.public(**kwargs).select_related(
887+
'project',
888+
'project__main_language_project',
889+
).prefetch_related(
890+
Prefetch(
891+
'project__superprojects',
892+
ProjectRelationship.objects.all().select_related('parent'),
893+
to_attr='_superprojects',
894+
),
895+
Prefetch(
896+
'project__domains',
897+
Domain.objects.filter(canonical=True),
898+
to_attr='_canonical_domains',
899+
),
900+
)
886901
return sort_version_aware(versions)
887902

888903
def all_active_versions(self):
@@ -980,6 +995,26 @@ def add_subproject(self, child, alias=None):
980995
def remove_subproject(self, child):
981996
ProjectRelationship.objects.filter(parent=self, child=child).delete()
982997

998+
def get_parent_relationship(self):
999+
"""Get the parent project relationship or None if this is a top level project"""
1000+
if hasattr(self, '_superprojects'):
1001+
# Cached parent project relationship
1002+
if self._superprojects:
1003+
return self._superprojects[0]
1004+
return None
1005+
1006+
return self.superprojects.select_related('parent').first()
1007+
1008+
def get_canonical_custom_domain(self):
1009+
"""Get the canonical custom domain or None"""
1010+
if hasattr(self, '_canonical_domains'):
1011+
# Cached custom domains
1012+
if self._canonical_domains:
1013+
return self._canonical_domains[0]
1014+
return None
1015+
1016+
return self.domains.filter(canonical=True).first()
1017+
9831018
@property
9841019
def features(self):
9851020
return Feature.objects.for_project(self)

readthedocs/rtd_tests/tests/test_footer.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,53 @@ def test_highest_version_without_tags(self):
225225
}
226226
returned_data = get_version_compare_data(self.pip, base_version)
227227
self.assertDictEqual(valid_data, returned_data)
228+
229+
230+
class TestFooterPerformance(APITestCase):
231+
fixtures = ['test_data']
232+
url = '/api/v2/footer_html/?project=pip&version=latest&page=index&docroot=/'
233+
factory = APIRequestFactory()
234+
235+
# The expected number of queries for generating the footer
236+
# This shouldn't increase unless we modify the footer API
237+
EXPECTED_QUERIES = 9
238+
239+
def setUp(self):
240+
self.pip = Project.objects.get(slug='pip')
241+
self.pip.versions.create_latest()
242+
243+
def render(self):
244+
request = self.factory.get(self.url)
245+
response = footer_html(request)
246+
response.render()
247+
return response
248+
249+
def test_version_queries(self):
250+
# The number of Versions shouldn't impact the number of queries
251+
with self.assertNumQueries(self.EXPECTED_QUERIES):
252+
response = self.render()
253+
self.assertContains(response, '0.8.1')
254+
255+
for patch in range(3):
256+
identifier = '0.99.{}'.format(patch)
257+
self.pip.versions.create(
258+
verbose_name=identifier,
259+
identifier=identifier,
260+
type=TAG,
261+
active=True,
262+
)
263+
264+
with self.assertNumQueries(self.EXPECTED_QUERIES):
265+
response = self.render()
266+
self.assertContains(response, '0.99.0')
267+
268+
def test_domain_queries(self):
269+
# Setting up a custom domain shouldn't impact the number of queries
270+
self.pip.domains.create(
271+
domain='http://docs.foobar.com',
272+
canonical=True,
273+
)
274+
275+
with self.assertNumQueries(self.EXPECTED_QUERIES):
276+
response = self.render()
277+
self.assertContains(response, 'docs.foobar.com')

0 commit comments

Comments
 (0)