Как использовать скрытые `проходные` модели Django в `Q` выражении
Вчера я узнал, что можно использовать выражение through в ключевом пути, который находится в выражении Q. И я узнал, что при фильтрации с использованием полей в связанных таблицах M:M результирующий набор запросов больше похож на true join (где запись корневой таблицы дублируется и объединяется с несколькими записями связанных таблиц).
Я узнал, что этого можно добиться, используя ModelA.mmrelatedModelB.through.filter() в оболочке. Например, у меня есть 106 записей PeakGroup, и каждая PeakGroup для соединения "цитрат" также связана с соединением "изоцитрат", поэтому если я запрошу 1, я получу 106 записей. А если я сделаю запрос по любому из них, я получу 212 записей:
In [16]: PeakGroup.compounds.through.objects.filter(Q(compound__name__exact="citrate")).count()
Out[16]: 106
In [17]: PeakGroup.compounds.through.objects.filter(Q(compound__name__exact="citrate") | Q(compound__name__exact="isocitrate")).count()
Out[17]: 212
Однако, в комментариях к этому ответу стека я узнал, что я должен быть в состоянии сделать то же самое только в выражении Q, без ссылки на PeakGroup.compounds.through. Я представил, что выражение может выглядеть примерно так:
PeakGroup.objects.filter(Q(through_peakgroup_compound__compound__name__exact="citrate") | Q(through_peakgroup_compound__compound__name__exact="isocitrate")).count()
но я не смог понять, как построить "путь", включающий сквозную модель...
Каждая попытка приводит к ошибке типа:
FieldError: Cannot resolve keyword 'through_peakgroup_compound' into field.
Возможно, в комментариях того ответа, связанного со стеком, произошло недопонимание, и это невозможно через Q-выражение только в одном фильтре? Так какой правильный "путь" использовать вместо "through_peakgroup_compound__compound__name__exact"?
Вот модельные отношения:
class PeakGroup(Model):
compounds = models.ManyToManyField(
Compound,
related_name="peak_groups",
help_text="The compound(s) that this PeakGroup is presumed to represent.",
)
class Compound(Model):
name = models.CharField(
max_length=256,
unique=True,
)
Мотивация
Причина, по которой я надеялся, что один фильтр со сложным выражением Q возможен, заключается в том, что у нас довольно большой и сложный интерфейс расширенного поиска, который использует 3 составных представления, объединяющих примерно дюжину моделей каждое, включая несколько отношений M:M. Пользователи могут строить сложные запросы с and-группами и or-группами с терминами из любой из моделей. В настоящее время результаты объединяют записи из этих моделей M:M с помощью разделителя в ячейке одной строки, но новым требованием следующего выпуска является разделение строк вывода по одной из этих моделей, связанных с M:M, так что, используя пример выше, одна строка будет отображать "цитрат", а другая - "изоцитрат в колонке "Состав", вместо нынешних "цитрат; изоцитрат" в одной строке. И если они будут искать "цитрат", то среди результатов не будет ни одной строки, содержащей "изоцитрат"
Если кто-то не сможет продемонстрировать обратное, похоже, что невозможно включить спрятанную through таблицу (то, что я бы назвал SQL "связующей таблицей") ни в .filter(), ни в Q() выражение. Только явно определенные through таблицы могут быть запрошены таким образом, например:
class PeakGroup(Model):
compounds = models.ManyToManyField(
Compound,
through="Measurement",
related_name="peak_groups",
help_text="The compound(s) that this PeakGroup is presumed to represent.",
)
class Compound(Model):
name = models.CharField(
max_length=256,
unique=True,
)
class Measurement(Model):
peakgroup = ForeignKey(PeakGroup, on_delete=models.CASCADE)
compound = ForeignKey(Compound, on_delete=models.CASCADE)
Затем вы можете включить эту таблицу в выражения фильтра и Q, например:
PeakGroup.objects.filter(Q(measurement__compound__name="citrate") | Q(measurement__compound__name="isocitrate"))
Однако в этом случае вы все еще только посылаете PeakGroup записи в шаблон, а связанные с ними записи измерений/компонентов должны быть получены заново во вложенном for цикле, так что это не позволяет мне сделать то, на что я надеялся.
Обратите внимание, что в демонстрации того, что я хотел, у меня был недостаток, который я не осознал. Я не понял, что записи, которые будут отправлены в шаблон в случае PeakGroup.compounds.through - это записи связывающей таблицы (т.е. записи Measurement), а не PeakGroup, как я упустил из виду. Вот почему это проблема...
Если у вас несколько связывающих таблиц в представлении (как это делаю я), вы столкнетесь с той же проблемой в следующей таблице (если только вы не отправляете несколько наборов запросов (которые наша существующая кодовая база не поддерживает)), поэтому использование сквозной/связывающей таблицы таким образом не является масштабируемым решением. Это может работать для одного отношения M:M, но не более.
Я нашел обходной путь, который действительно решает общую проблему (желание отобразить и постранично отобразить один набор результатов запроса, который проходит через несколько связывающих таблиц, как если бы это был настоящий SQL left-join).
Основная предпосылка, благодаря которой работает мой обходной путь, заключается в том, что перед отправкой набора запросов в шаблон, набор запросов имеет доступ к полным/истинным объединенным данным. Он связывает каждую дублирующую запись PeakGroup с одной записью Compound, что мне и нужно в шаблоне. Вы теряете доступ к этим связям только тогда, когда отправляете полученный queryset в шаблон, вот почему все всегда просто используют вложенные циклы for в шаблонах в Django для отображения связанных записей M:M.
Обратите внимание, Django вернет вам дубликаты записей PeakGroup, каждый из которых связан с одной записью Compound (как вы ожидаете в левом соединении), и вы можете получить к ним доступ, прежде чем отправить набор запросов в шаблон, используя выражение F.
Work-around
Обратите внимание, что для этого не нужно явно определять through таблицу.
Чтобы воспользоваться доступом к данным full-left-join и встроить ассоциации M:M в кверисет так, чтобы их можно было реконструировать в шаблоне, вам нужно 2 вещи:
- Вызов
.distinct(join_key_list), предоставляющий первичные ключи, которые делают соединение различимым - Вызов
.annotate(**mm_key_values), создающий аннотацию со значением первичного ключа связанной таблицы M:M (посредством выраженияF)
Используя мои первые 2 примера, это будет выглядеть следующим образом:
Запрос только на цитрат:
In [13]: pgs = PeakGroup.objects.filter(Q(compounds__name__iexact="citrate")).distinct('name', 'pk', 'compounds__name', 'compounds__pk').annotate(compound=F("compounds__pk"))
...: print(pgs.count())
...: for pg in pgs:
...: for cp in pg.compounds.all():
...: if pg.compound == cp.pk:
...: print(f"{pg.pk} {pg.name} {cp.pk} {cp.name}")
...:
56
4 citrate/isocitrate 12 citrate
11 citrate/isocitrate 12 citrate
18 citrate/isocitrate 12 citrate
25 citrate/isocitrate 12 citrate
32 citrate/isocitrate 12 citrate
...
Запрос на цитрат или изоцитрат:
In [12]: pgs = PeakGroup.objects.filter(Q(compounds__name__iexact="citrate") | Q(compounds__name__iexact="isocitrate")).distinct('name', 'pk', 'compounds__name', 'compounds__pk').annotate(compound=F("compounds__pk"))
...: print(pgs.count())
...: for pg in pgs:
...: for cp in pg.compounds.all():
...: if pg.compound == cp.pk:
...: print(f"{pg.pk} {pg.name} {cp.pk} {cp.name}")
...:
112
4 citrate/isocitrate 12 citrate
4 citrate/isocitrate 28 isocitrate
11 citrate/isocitrate 12 citrate
11 citrate/isocitrate 28 isocitrate
18 citrate/isocitrate 12 citrate
18 citrate/isocitrate 28 isocitrate
...
Раздражает, что для фильтрации неиспользуемых записей, связанных с M:M, приходится зацикливаться, но это работает хорошо, поскольку позволяет мне:
- Получите точный подсчет "объединенных" записей.
- Используйте пагинацию на стороне сервера
- Отображение "истинного" представления левого соединения SQL в шаблоне
Примечание: я все еще использую .prefetch_related() (здесь не показано), в который я поставляю Prefetch() объекты, содержащие копию оригинального фильтра, поставляемого в опции queryset, только пути, поставляемые в фильтре, "перекореняются"^^. Это обеспечивает значительный прирост производительности.
Я также написал тег шаблона (simple_tag) под названием get_manytomany_rec, который принимает записи таблицы M:M (pg.compounds.all) и значение поля аннотации (например, pg.compound) и извлекает соответствующую составную запись для этой строки. И я сделал это таким образом, что если я установлю для этой M:M таблицы значение не разделять строки результата, то она просто возвращает предоставленные pgs.compounds.all записи. Использование тега шаблона выглядит следующим образом:
{% get_manytomany_rec pg.compounds.all pg.compound as compounds %}
{% for mcpd in compounds %}<a href="{% url 'compound_detail' mcpd.id %}">{{ mcpd.name }}</a>; {% endfor %}
(Примечание, у меня есть параметр, который можно переключить, чтобы таблица, связанная с M:M, отображалась как разграниченное значение ; в 1 строке или чтобы разделить 1 строку на несколько строк)
Я бы любил иметь лучшее/проще решение, чем этот обходной путь, но пока кто-нибудь не предоставит лучший ответ, это лучшее, что я мог сделать.