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 Entry — Tag.
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
будет джойниться трижды. Или например, если количество связей Entry — Tag превысит некоторое пороговое значения, когда объем данных (а он, я обращаю внимание, из-за декартова произведения будет расти “по параболе”) перестанет умещаться в память, и все это начнет выгружаться на диск…
Что делать? Конкретно в данном примере это правится довольно просто:
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, слишком уж много сюрпризов она может прятать "под капотом". Доверяй но проверяй.