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

от автора

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, слишком уж много сюрпризов она может прятать «под капотом». Доверяй но проверяй.


ссылка на оригинал статьи https://habr.com/ru/articles/913190/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *