Django cached_property НЕ кэшируется

В моих моделях есть следующее:

class Tag(models.Model):
    name = models.CharField(max_length=255)
    type = models.CharField(max_length=1)
    person = models.ForeignKey(People, on_delete=models.CASCADE)

class People(models.Model):
    name = models.CharField(max_length=255)

    @cached_property
    def tags(self):
        return Tag.objects.filter(person=self, type="A")

Я ожидаю, что когда я сделаю это:

person = People.objects.get(pk=1)
tags = person.tags

Что это приведет к 1 запросу к базе данных - только получение человека из базы данных. Однако, это постоянно приводит к 2 запросам - таблица tags постоянно запрашивается, несмотря на то, что она якобы кэшируется. Что может быть причиной этого? Может я неправильно использую свойство cached_property?

Модели упрощены для иллюстрации этого случая.

Вы украсили tags() методы возвращают queryset, который еще не оценен. (Подробнее о том, когда оценивается кверисет, читайте в документации Django). Чтобы кэшировать результаты запроса, необходимо заставить кверисет сначала вычислить список объектов:

class People(models.Model):
    name = models.CharField(max_length=255)

    @cached_property
    def tags(self):
        return list(Tag.objects.filter(person=self, type="A"))

Трудно понять, что не так, не видя кода, который фактически вызывает кэшированное свойство несколько раз. Однако, судя по тому, как вы описали свою проблему, cached_property кажется, что это правильный подход и он должен работать.

Мое предположение заключается в том, что может быть некоторое недопонимание того, как это работает. Примером использования кэшированного свойства может быть:

person = People.objects.get(pk=1)  # <- Query on People executed
text_tags = ', '.join(person.tags)  # <- Query on Tags executed
html_tags = format_html_join(
    '\n',
    '<span class="tag tag-{}">{}</span>',
    ((t.type, t.name) for t in person.tags),  # <- tags loaded from cache, no query executed
)

Однако, если вы сделаете что-то вроде этого:

for person in People.objects.all(): # <- Query on People executed
    text_tags = ', '.join(person.tags)  # <- Query on Tags executed FOR EACH ITERATION
    html_tags = format_html_join(
        '\n',
        '<span class="tag tag-{}">{}</span>',
        ((t.type, t.name) for t in person.tags),  # <- tags loaded from cache, no query executed
    )

Первый вызов person.tags каждой итерации цикла for выполняет запрос. Это происходит потому, что результат свойства tags кэшируется для каждого экземпляра.

Если вы хотите заранее кэшировать все нужные вам теги при итерации над объектами людей, существует несколько подходов в зависимости от вашего случая использования.


Ручной подход

from itertools import groupby

all_tags = Tags.objects.filter(type="A").order_by('person_id')
# order_by() is important because we will use person_id as key to group the results using itertools.groupby()

# Create a dictionary matching a person to it's list of tags using a single SQL query
people_tags = {
    person_id: list(tags)
    for person_id, tags in groupby(all_tags, lambda t: t.person_id)
}

for person in People.objects.all():
    # Try to find the person's tags in the dictionary, otherwise, set tags to an empty list
    tags = people_tags.get(person.id, [])

Подход с одним запросом и агрегациями

При таком подходе вам нужно убедиться, что ваш внешний ключ имеет связанное имя, чтобы иметь возможность делать "обратные" запросы:

class Tag(models.Model):
    name = models.CharField(max_length=255)
    type = models.CharField(max_length=1)
    person = models.ForeignKey(
        People,
        on_delete=models.CASCADE,
        related_name='tags',
    )

Указание related_name не является строго обязательным, поскольку Django дает связанное имя по умолчанию, но я никогда не могу вспомнить, как строится это имя, поэтому я всегда указываю его явно.

Не забудьте удалить метод tags(), поскольку его название будет конфликтовать с родственным названием "tags".

from django.db.models import Q
from django.contrib.postgres.aggregates import ArrayAgg


persons = (
    People.objects.all()
    .annotate(tags_names=ArrayAgg('tags__name', filter=Q(tags__type='A')))
)
for person in persons:
    tags = person.tags_names

Обратите внимание, что при таком подходе person.tags_names будет список имен тегов в виде строк, а не список объектов Tag. Есть несколько хитрых способов получения объектов Tag или, по крайней мере, более чем одного поля, используя annotate(), но я думаю, что это выходит за рамки данного вопроса.

Также обратите внимание, что это будет работать только с PostgreSQL.


Встроенный способ Django: prefetch_related()

Django поставляется с методом prefetch_related() для объектов QuerySet. Он специально разработан как сокращение ручного подхода. Этот подход требует использования внешнего ключа related_name, упомянутого выше.

from django.db.models import Prefetch

persons = (
    People.objects.all()
    .prefetch_related(
        Prefetch('tags', queryset=Tag.objects.filter(type='A'))
    )
)
for person in persons:
    tags = person.tags

Обратите внимание, что если вам не нужно фильтровать теги по типу, вы можете просто сделать People.objects.prefetch_related('tags').

Вернуться на верх