2025, Oct 23 09:16
Как сделать AND-фильтр по ManyToMany в Django одним запросом
Показываем, как в Django сделать AND-фильтр по ManyToMany без лишних JOIN и подзапросов: один запрос с Count(distinct) и предсказуемой производительностью.
Когда в деле связь ManyToMany, собрать фильтр с логикой AND бывает неочевидно. Выбрать объекты, которые соответствуют хотя бы одной из связанных записей, просто; но требование совпадения по всем заданным связанным значениям обычно подталкивает к неэффективным подзапросам для каждого элемента. Ниже — аккуратный, основанный на операциях над множествами подход: он укладывается в один запрос и не требует перебирать большие входные списки.
Постановка задачи
Возьмем две модели, связанные ManyToMany: одна описывает сущности с именами, другая — локации, в которых эти сущности присутствуют. Нужно получить все локации, у которых есть элементы с именами из переданного списка — как с логикой OR (подходит любое совпадение), так и с логикой AND (должны совпасть все).
class Volume(Model):
    name = CharField(...)
    ...
class Outlet(Model):
    volumes = ManyToManyField('Volume', blank=True, related_name='outlets')
    ...
Запрос с OR прост: локация подходит, если связана хотя бы с одним из указанных имен.
Outlet.objects.filter(volumes__name__in=item_names)
С AND сложнее. Наивный подход — перебрать имена, накапливая пересечение условий. Это приводит к множественным JOIN'ам или подзапросам на каждое имя и плохо масштабируется.
from django.db.models import Q
clauses = Q()
for nm in name_list:
    clauses &= Q(id__in=Volume.objects.get(name=nm).outlets)
Outlet.objects.filter(clauses)
Почему наивный AND дает сбой
Связывание условий для каждого значения оборачивается растущей цепочкой JOIN'ов и вложенных выборок. Даже при нескольких значениях это неоптимально, а при больших пользовательских списках быстро превращается в проблему производительности. Попытки выразить AND через несколько фильтров по одной и той же связи нередко сводятся к поведению, похожему на OR, или учитывают лишь одну связанную строку.
Решение на основе агрегирования и множеств
Идея проста: свести задачу к подсчету совпадений. Сначала преобразуйте входные имена в множество, чтобы убрать дубликаты. Затем отфильтруйте локации по этим именам, добавьте аннотацию с количеством подошедших связанных элементов и потребуйте, чтобы это число равнялось размеру входного множества. Такое равенство и реализует логику AND без явных циклов по каждому значению.
from django.db.models import Count
name_pool = set(item_names)
Outlet.objects.filter(
    volumes__name__in=name_pool
).annotate(
    match_count=Count('volumes')
).filter(
    match_count=len(name_pool)
)
Если запрос обрастает дополнительными JOIN'ами и появляются дубликаты строк, используйте подсчет с distinct, чтобы сохранить корректность агрегации.
from django.db.models import Count
name_pool = set(item_names)
Outlet.objects.filter(
    volumes__name__in=name_pool
).annotate(
    match_count=Count('volumes', distinct=True)
).filter(
    match_count=len(name_pool)
)
Что это дает
Эта стратегия выражает семантику AND одной реляционной операцией: фильтруем по множеству кандидатов и проверяем, что число уникальных совпадений равно числу запрошенных имен. Мы избегаем перебора в Python, не делаем подзапросов на каждое значение и сохраняем эффективность при большом входе. Использование множества гарантирует корректную целевую величину при наличии дубликатов, а переход на distinct-счетчик сохраняет точность, когда в запросе появляются дополнительные JOIN'ы.
Итоги
Для AND-фильтров по ManyToMany в Django предпочтителен подход с агрегированием. Отфильтруйте по кандидатным именам, аннотируйте количество связанных строк и сравните его с размером дедуплицированного входа. Если форма запроса добавляет лишние JOIN'ы, делайте подсчет distinct. Такой паттерн держит логику декларативной, сам запрос — компактным, а производительность — предсказуемой по мере роста входных данных.
Статья основана на вопросе на StackOverflow от Valkoinen и ответе willeM_ Van Onsem.