Как использовать скрытые `проходные` модели 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, приходится зацикливаться, но это работает хорошо, поскольку позволяет мне:

  1. Получите точный подсчет "объединенных" записей.
  2. Используйте пагинацию на стороне сервера
  3. Отображение "истинного" представления левого соединения 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 строку на несколько строк)

Я бы любил иметь лучшее/проще решение, чем этот обходной путь, но пока кто-нибудь не предоставит лучший ответ, это лучшее, что я мог сделать.

Вернуться на верх