In the django docs for the "with" template tag, it says that this tag..
is useful when accessing an “expensive” method (e.g., one that hits the database) multiple times.
However, when I use it in my own template, I've observed (using django debug toolbar) that each time I call the variable that I create using the "with" template tag, it results in duplicate queries to the database if I perform an operation on that variable within another template tag. Why is this the case? Shouldn't I be able to do things to that variable without it querying the database?
Below is my abridged and simplified code.
## models.py
class Contributor(models.Model):
name = models.CharField(max_length=100)
class Contribution(models.Model):
contributor = models.ForeignKey(
Contributor, on_delete=models.RESTRICT, related_name='contributions'
)
class ROLE_CHOICES(models.TextChoices):
A = 'AUTHOR', _('Author')
C = 'COAUTHOR', _('Co-Author')
E = 'EDITOR', _('Editor')
V = 'VOICE', _('Voice Actor')
S = 'AUDIO', _('Audio Engineer')
role = models.CharField(max_length=10, choices=ROLE_CHOICES.choices)
class Scenario(models.Model):
title = models.CharField(max_length=200)
contributions = models.ManyToManyField(
Contribution, related_name='contributions'
)
## views.py
class ScenarioDetailView(generic.DetailView):
model = Scenario
Here is my custom template tag code:
## custom_tags.py
@register.filter
def only_role(scenario, test_role):
return scenario.contributions.filter(role=test_role)
scenario_detail.html
{% load custom_tags %}
<dl>
{% with authors=scenario|only_role:"AUTHOR" %}
{% if authors.count > 0 %}
<dt>Author{{ authors.count|pluralize }}</dt>
{% for contribution in authors %}
<dd>{{ contribution.contributor.name }}</dd>
{% endfor %}
{% endif %}
{% comment %}The code in the "if" block would be repeated for all the possible roles, and I would add a variable to the "with" template tag for each role.{% endcomment %}
{% endwith %}
</dl>
Here are the duplicate queries on the django debug toolbar readout:
SELECT COUNT(*) AS "__count" FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR')
2 similar queries. Duplicated 2 times. 2.76
Sel Expl
SELECT COUNT(*) AS "__count" FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR')
2 similar queries. Duplicated 2 times. 0.72
Sel Expl
SELECT ••• FROM "scenario_contribution" INNER JOIN "scenario_scenario_contributions" ON ("scenario_contribution"."id" = "scenario_scenario_contributions"."contribution_id") INNER JOIN "scenario_contributor" ON ("scenario_contribution"."contributor_id" = "scenario_contributor"."id") LEFT OUTER JOIN "scenario_participant" ON ("scenario_contribution"."character_id" = "scenario_participant"."id") WHERE ("scenario_scenario_contributions"."scenario_id" = 'demo' AND "scenario_contribution"."role" = 'AUTHOR') ORDER BY "scenario_contributor"."last_name" ASC, "scenario_contributor"."first_name" ASC, "scenario_contribution"."role" ASC, "scenario_participant"."part_type" ASC, "scenario_participant"."designation" ASC
Do the duplicate queries stem from the fact that I'm using the "count" method on the variable "authors"? I tried the dummy template code below, and it dropped the number of queries by 2, so that would suggest this is the case.
{% with authors=scenario|only_role:"AUTHOR" %}
{% if authors %}
<dt>{{ authors }}</dt>
{% for contribution in authors %}
<dd>{{ contribution.contributor.first_name }}</dd>
{% endfor %}
{% endif %}
{% endwith %}
If so, how might I, for example, return the count information in a single custom tag? Can I return two values in one custom tag, or would I need a separate custom tag? (in other words, have one custom tag that returns a filtered queryset as well as the number of objects). I've written a custom tag that counts the number of objects, and I've gotten the number of queries for this specific block of template down to 2 instead of 3 by using it, but it would be nice to have it down to 1 query.
@register.filter
def num_role(scenario, test_role):
return scenario.contributions.filter(role=test_role).count
scenario_detail.html
{% load custom_tags %}
<dl>
{% with authors=scenario|only_role:"AUTHOR" n_authors=scenario|num_role:"AUTHOR" %}
{% if n_authors > 0 %}
<dt>Author{{ n_authors|pluralize }}</dt>
{% for contribution in authors %}
<dd>{{ contribution.contributor.name }}</dd>
{% endfor %}
{% endif %}
{% comment %}The code in the "if" block would be repeated for all the possible roles, and I would add a variable to the "with" template tag for each role.{% endcomment %}
{% endwith %}
</dl>
Why is this the case? Shouldn't I be able to do things to that variable without it querying the database?
A QuerySet
does not query the database, or at least not instantly when it is constructed. It is basically a promise to query the database when necessary, and .count()
thus will query the database. You constructed one QuerySet
, but this will make multiple queries.
A QuerySet
can however cache the result if it is evaluated. If you use len(…)
it does not count the records, or at least by a query, it loads the records, and then checks how many records have been loaded.
We can exploit this to work with the |length
template filter [Django-doc], which will force evaluation of the QuerySet
, and can be reused:
{% load custom_tags %}
<dl>
{% with authors=scenario|only_role:"AUTHOR" %}
{% if authors|length %}
<dt>Author{{ authors|length|pluralize }}</dt>
{% for contribution in authors %}
<dd>{{ contribution.contributor.name }}</dd>
{% endfor %}
{% endif %}
{% endwith %}
</dl>
The {% if authors|length %}
can also be replaced with {% if authors %}
, since a QuerySet
has truthiness True
if it contains at least one record.