Как стать автором
Обновить

Некоторые неочевидные особенности Django ORM (filter и exclude)

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров999

TLDR: В статье рассказывается о некоторых особенностях Django ORM, а именно, как при неправильном использовании некоторых встроенных методов (filter(), exclude()) можно незаметно, но очень больно, выстрелить себе в ногу при работе со связями many-to-many и one-to-many (связь, обратная к FK). Статья может быть полезной не слишком искушенному в тонкостях Django ORM разработчику.


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


Собственно вот эта часть документации: https://docs.djangoproject.com/en/5.2/topics/db/queries/#spanning-multi-valued-relationships Быстро посмотрим, что там:


class Blog(models.Model):
    name = models.CharField(max_length=100)
    tagline = models.TextField()

class Entry(models.Model):
    blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
    headline = models.CharField(max_length=255)
    body_text = models.TextField()
    pub_date = models.DateField()

… Если нам нужно выбрать все блоги (Blog), содержащие хотя бы одну запись (Entry) от 2008 года, имеющую “Lennon” в своем заголовке, то запрос будет следующим.


Blog.objects.filter(entry__headline__contains="Lennon", entry__pub_date__year=2008)

Если же, нас интересуют такие блоги, в которых есть как записи от 2008 года, так и записи, содержащие Lennon в своем заголовке, то нужно писать


Blog.objects.filter(entry__headline__contains="Lennon").filter(entry__pub_date__year=2008)

В этом случае это могут быть разные записи в одном блоге — одна которая удовлетворяет одному условию, вторая — другому.
Таким образом, если нужно, чтобы оба условия применялись к одной и той же записи, то они должны быть перечислены внутри одного .filter().


Казалось бы, ну понятно же все, RTFM, но я пойду немного дальше и расскажу, какие последствия незнания мы поймали на практике, и что с этим делали. И на мой взгляд, гораздо нагляднее и гораздо ближе к реальности будет показать это на примере связи many-to-many EntryTag.


class Blog(models.Model):  
    name = models.CharField(max_length=100)  

class Tag(models.Model):  
    name = models.CharField(max_length=100)  

    def __str__(self):  
        return self.name  

class Entry(models.Model):  
    blog = models.ForeignKey(Blog, on_delete=models.CASCADE)  
    tags = models.ManyToManyField(Tag)  

    headline = models.CharField(max_length=255)  
    body_text = models.TextField()  
    is_hidden = models.BooleanField(default=False)

Допустим, есть вот такой хелпер для облака тегов, который собирает список всех тегов нескрытых записей конкретного блога.


def get_tags(blog_id: int = None) -> QuerySet:  
    qs = Tag.objects.filter(entry__is_hidden=False)  
    if blog_id is not None:  
        qs = qs.filter(entry__blog_id=blog_id)  

    return qs.distinct()

Тут мы обращаемся к Entry в разных .filter(). Это конечно же ошибка (теперь-то мы знаем), и ниже посмотрим, как она проявляется на практике.


Заполним БД:


blog_beatles = Blog.objects.create(name="Beatles Blog")  
blog_pop = Blog.objects.create(name="Pop Music Blog")  
tag_lennon = Tag.objects.create(name="Lennon")  
tag_beatles = Tag.objects.create(name="Beatles")  
tag_biography = Tag.objects.create(name="Biography")  
tag_hip_hop = Tag.objects.create(name="Hip-hop")  

Entry.objects.create(  
    blog=blog_beatles,  
    headline="New Lennon Biography",  
    is_hidden=True,  
).tags.set([tag_lennon, tag_biography, ])  

Entry.objects.create(  
    blog=blog_beatles,  
    headline="Full Beatles Discography",  
    is_hidden=False,  
).tags.set([tag_lennon, tag_beatles, ])  

Entry.objects.create(  
    blog=blog_pop,  
    headline="Lennon Would Have Loved Hip Hop",  
    is_hidden=False,  
).tags.set([tag_lennon, tag_hip_hop, tag_biography, ])

Теперь, если вызвать get_tags(blog_id=blog_beatles.id), то среди тегов мы увидим тег Biography.


>>> get_tags(1)
<QuerySet [<Tag: Lennon>, <Tag: Biography>, <Tag: Beatles>]>

Потому что есть нескрытая запись с таким тегом, и есть запись с таким тегом в целевом блоге. Да, это разные записи, но поскольку запрос составлен “более мягким“ образом, то этот тег мы увидим в ответе.


Вторая проблема становится отчетливо видна, если посмотреть на выполняемый SQL-запрос.


SELECT DISTINCT "example_tag"."id",
       "example_tag"."name"
  FROM "example_tag"
 INNER JOIN "example_entry_tags"
    ON ("example_tag"."id" = "example_entry_tags"."tag_id")
 INNER JOIN "example_entry"
    ON ("example_entry_tags"."entry_id" = "example_entry"."id")
 INNER JOIN "example_entry_tags" T4
    ON ("example_tag"."id" = T4."tag_id")
 INNER JOIN "example_entry" T5
    ON (T4."entry_id" = T5."id")
 WHERE (NOT "example_entry"."is_hidden" AND T5."blog_id" = 1)
 LIMIT 21

Здесь мы видим, что промежуточная таблица для M2M-связи example_entry_tags (и вслед за ней example_entry) джойнится дважды, а поскольку нет условия, которое бы связывало example_entry_tags и T4, то по факту к таблице example_tags мы джойним декартово произведение таблицы example_entry_tags саму на себя. И здесь проблема может быть незаметной (база все стерпит) до какого-то критического момента. Таковым может стать добавление еще одного .filter(), после чего таблица example_entry_tags будет джойниться трижды. Или например, если количество связей EntryTag превысит некоторое пороговое значения, когда объем данных (а он, я обращаю внимание, из-за декартова произведения будет расти “по параболе”) перестанет умещаться в память, и все это начнет выгружаться на диск…


Что делать? Конкретно в данном примере это правится довольно просто:


def get_tags_fixed(blog_id: int = None) -> QuerySet:  
    conditions = [Q(entry__is_hidden=False)]  
    if blog_id is not None:  
        conditions.append(Q(entry__blog_id=blog_id))  

    return Tag.objects.filter(*conditions).distinct()

… но с гораздо бОльшими проблемами мы столкнулись, когда это был разбросанный по разным методам код. Например, у нас были кастомные менеджеры моделей и новые фильтры добавлялись внутри них. Что-то типа


class TagManager(models.Manager):
    def filter_by_visibility(self):
        return self.filter(entry__is_hidden=False)

    def filter_by_blog(self, entry_blog_id: int = None):
        if entry_blog_id is not None:
            return self.filter(entry__blog_id=entry_blog_id)
        return self

class Tag(models.Model):
    ...
    objects = TagManager()

И здесь нам пришлось довольно сильно перелопачивать код — где-то объединять такие “конфликтующие” фильтры в один (а некоторые из них были действительно большими), где-то отказываться от них — выносить логику из мелких фильтров наружу.


Добавлю здесь еще небольшой лайфхак, как это можно отловить (если есть подозрения, а кодовая база довольно крупная) — по запросам к БД. И на мой взгляд, лучше всего тут подойдут тестовые прогоны. Поскольку у нас код довольно неплохо был покрыт юнит-тестами, то мы их прогоняли с логированием запросов, которые потом проверяли по примерно такой регулярке (синтаксис Python для бэктрекинга в регулярках: https://www.regular-expressions.info/named.html ).


r'INNER JOIN (?P<linked_table>"\w+") ON \((?P<original_table_field>"\w+"\."\w+") = (?P=linked_table)\.(?P<linked_field>"\w+")\).*'
r'INNER JOIN (?P=linked_table) (?P<linked_alias>\w+) ON \((?P=original_table_field) = (?P=linked_alias)\.(?P=linked_field)\)'

Да, могут быть ложные срабатывания, но круг подозреваемых все равно довольно сильно сужается. Это не панацея, но нам помогло.


И еще немного про .exclude(). Если бы исходный хелпер был бы написан таким образом:


def get_tags_with_exclude(blog_id: int = None) -> QuerySet:  
    qs = Tag.objects.exclude(entry__is_hidden=True)  
    if blog_id is not None:  
        qs = qs.filter(entry__blog_id=blog_id)  

    return qs.distinct()

то вызов get_tags_with_exclude(blog_id=blog_beatles) вернул бы только


<QuerySet [<Tag: Beatles>]>

а под капотом породил бы следующий SQL-запрос с подзапросом с EXISTS


SELECT DISTINCT "example_tag"."id",
       "example_tag"."name"
  FROM "example_tag"
 INNER JOIN "example_entry_tags"
    ON ("example_tag"."id" = "example_entry_tags"."tag_id")
 INNER JOIN "example_entry"
    ON ("example_entry_tags"."entry_id" = "example_entry"."id")
 WHERE (NOT (EXISTS(SELECT 1 AS "a" FROM "example_entry_tags" U1 INNER JOIN "example_entry" U2 ON (U1."entry_id" = U2."id") WHERE (U2."is_hidden" AND U1."tag_id" = ("example_tag"."id")) LIMIT 1)) AND "example_entry"."blog_id" = 1)

Я понимаю, почему тег Lennon не попал в выдачу — потому что он встречается в скрытой записи, а .exclude() работает именно таким образом (и это не баг, а фича, и ее всегда нужно держать в уме). Но в какой-то очередной раз, споткнувшись о такое поведение, я решил окончательно отказаться от .exclude() вообще. Неудобно же, и мне непонятна логика создателей Django, почему они не добавили lookup для “не равно” (но к счастью, описали прямо в документации, как это сделать https://docs.djangoproject.com/en/5.2/howto/custom-lookups/#a-lookup-example ), а вместо этого рекомендуют использовать или .filter(~Q()), или .exclude(), хотя первый вариант более громоздкий, а второй — работает не всегда точно так же, как простое “не равно”.


Собственно, это все, что хотел здесь рассказать. Возможно, кто-то это и так уже знал, но надеюсь, что найдутся и те, для кого эта статья станет откровением.
И в конце не могу не порекомендовать почаще смотреть в то, какие SQL-запросы генерирует Django ORM, слишком уж много сюрпризов она может прятать "под капотом". Доверяй но проверяй.

Теги:
Хабы:
+6
Комментарии2

Публикации

Работа

Data Scientist
50 вакансий

Ближайшие события