如何在 `Q` 表达式中使用 Django 的隐藏 `through` 模型

How to use Django's hidden `through` models in a `Q` expression

TL;DR; 包含 through table(对于 m2m 相关 table 字段)的正确方法是什么Q 表达式?例如。我如何不引用 Q(compounds__name__iexact="citrate") 中的 m2m 字段“化合物”,而是如何引用当你有 ManyToManyField 时自动创建的 table 隐藏字段?我的猜测是 Q(through_peakgroup_compound__name__iexact="citrate"),但这会引发异常。

长版...

我昨天了解到,您可以在 Q 表达式中的关键路径^中使用 through 表达式。我了解到,当使用 M:M 相关 table 中的字段进行过滤时,生成的查询集更像是一个真正的 SQL 连接。我的意思在下面的示例中得到了更好的解释,但要点是,如果我通过 m2m 关系在字段上使用搜索词,我只会返回与这些搜索词匹配的 m2m 相关 table 记录,而不是每个 linked 记录 table.

我在 shell 中进行了试验,了解到我可以使用 ModelA.m2mModelB.through.filter() 来完成此操作。

例如,我有 56 条 PeakGroup 记录,通过多对多关系 link 到相同的 2 种化合物(柠檬酸盐和异柠檬酸盐)。

这是我不使用时最接近我想要的东西 through:

仅查询柠檬酸盐:

In [93]: pgs = PeakGroup.objects.filter(Q(compounds__name__iexact="citrate")).distinct()

In [94]: pgs.count()
Out[94]: 56

In [95]: for i in range(0, 56):
    ...:     for compound in pgs[i].compounds.all():
    ...:         print(", ".join(map(lambda s: str(s), [pgs[i].id, pgs[i].name, compound.id, compound.name])))
    ...: 
4, citrate/isocitrate, 12, citrate
4, citrate/isocitrate, 28, isocitrate
11, citrate/isocitrate, 12, citrate
11, citrate/isocitrate, 28, isocitrate
...

请注意,它给了我不需要的异柠檬酸记录,在 SQL 中,这些记录不会包含在左连接的行中。 (我能够通过使用重新根植^^版本的过滤器提供带有查询集的 Prefetch 来解决这个问题,但是它重新引入了下一个案例的问题,所以我不会去到那个。)

查询柠檬酸盐或异柠檬酸盐:

In [90]: pgs = PeakGroup.objects.filter(Q(compounds__name__iexact="citrate") | Q(compounds__name__iexact="isocitrate")).distinct()

In [91]: pgs.count()
Out[91]: 56

In [92]: for i in range(0, 56):
    ...:     for compound in pgs[i].compounds.all():
    ...:         print(", ".join(map(lambda s: str(s), [pgs[i].id, pgs[i].name, compound.id, compound.name])))
    ...: 
4, citrate/isocitrate, 12, citrate
4, citrate/isocitrate, 28, isocitrate
11, citrate/isocitrate, 12, citrate
11, citrate/isocitrate, 28, isocitrate
...

上面的优点在于,我得到了我查询的所有内容,但如果这些显示在模板中的 112 个单独的行上(每个化合物都在显示重复的 PeakGroup 数据的另一行上),我无法对其进行分页.

这是我使用时得到的(这就是我想要):

仅查询柠檬酸盐:

In [83]: pgs = PeakGroup.compounds.through.objects.filter(Q(compound__name__iexact="citrate"))

In [84]: pgs.count()
Out[84]: 56

In [85]: for i in range(0, 56):
    ...:     print(", ".join(map(lambda s: str(s), [pgs[i].peakgroup.id, pgs[i].peakgroup.name, pgs[i].compound.id, pgs[i].compound.name])))
    ...: 
4, citrate/isocitrate, 12, citrate
11, citrate/isocitrate, 12, citrate
...

^^完美!

查询柠檬酸盐或异柠檬酸盐:

In [57]: pgs = PeakGroup.compounds.through.objects.filter(Q(compound__name__iexact="citrate") | Q(compound__name__iexact="isocitrate"))

In [58]: pgs.count()
Out[58]: 112

In [59]: for i in range(0, 112):
    ...:     print(", ".join(map(lambda s: str(s), [pgs[i].peakgroup.id, pgs[i].peakgroup.name, pgs[i].compound.id, pgs[i].compound.name])))
    ...: 
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
...

^^完美!

然而,我在 this stack answer 下的评论中了解到,我应该能够在仅提供给单个过滤器的 Q 表达式中完成相同的事情,而无需引用 .compounds.through。由于仅在 PeakGroup 的一个过滤器中完成所有这些工作将使我的重构工作大大减少,我想学习如何做我被告知是可能的事情。

我开始使用试错法在 shell 中进行实验(因为我在文档中找不到任何描述此功能的内容)。我想表达式可能看起来像这样:

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.

也许在那个 linked 堆栈答案的评论中,有一个错误的传达,这个技巧实际上不可能通过直接应用于 PeakGroup.objects 的单个过滤器中的 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 相关模型之一的输出行,因此使用上面的示例,一行将显示“柠檬酸盐”,另一行将在“化合物”列中显示“异柠檬酸盐”,而不是当前在单行上显示“柠檬酸盐;异柠檬酸盐”。如果他们搜索“柠檬酸盐”,则结果中不会包含包含“异柠檬酸盐”的行。

^ - 通过“键路径”,我的意思是在路径中串在一起的外键,就像您将提供给 .filter()Q 表达式的内容,例如 modelBkey__modelCkey__modelCfieldname 的一部分:ModelA.objects.filter(modelBkey__modelCkey__modelCfieldname__exact="searchterm") 或在模板中包含相关字段,如 {{ queryset.modelBkey.modelCkey.modelCfieldname }}.

^^ - 通过“过滤器的重新根目录版本”,我的意思是,我采用原始 filter/Q 表达式,并将关键路径更改为从与 m2m 相关的 table 开始。它工作得很好,但它并没有解决我的全部问题。

除非有人能够证明,否则似乎不可能包含 hidden through table(我称之为SQL "linking table") 在 .filter()Q() 表达式中。只有显式定义 through table似乎能够以这种方式查询,例如:

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 表达式中包含 table,例如:

PeakGroup.objects.filter(Q(measurement__compound__name="citrate") | Q(measurement__compound__name="isocitrate"))

但是,在这种情况下,您仍然只向模板发送 PeakGroup 条记录,并且必须在嵌套 for 循环,所以这不允许我做我希望它会做的事情。

请注意,在演示我想要的内容时,我有一个我没有意识到的缺陷。我没有意识到 PeakGroup.compounds.through 情况下发送到模板的记录是链接 table 记录(即 Measurement 记录),而不是 PeakGroup 记录正如我所忽略的。这就是为什么这是一个问题...

如果您在视图中有多个链接 table(就像我一样),您将 运行 在下一个 table 遇到同样的问题,(除非您是发送多个查询集(我们现有的代码库不支持)),因此以这种方式使用 through/linking table 不是可扩展的解决方案。它可以用于一个 M:M 关系,但不能再多了。

我确实找到了一个 work-around,它确实解决了整个问题(想要显示和分页一组经过多重链接的查询结果 tables 就好像它是真的 SQL left-join).

使我的 work-around 工作的基本前提是 将查询集发送到模板之前,查询集可以访问 full/true 加入数据。它将每个重复的 PeakGroup 记录与单个 Compound 记录链接起来,这是我在模板中需要的。当您将生成的查询集发送到模板时,您只会失去对这些关联的访问权限,这就是为什么每个人总是简单地在 Django 模板中使用嵌套 for 循环来显示 M:M 相关记录。

注意,Django 会返回重复的 PeakGroup 记录,每个记录都连接到单个 Compound 记录(正如您在左连接中所期望的那样)并且您可以在发送之前访问它查询集到模板,使用 F 表达式。

Work-around

注意,您不需要显式定义 through table 即可工作。

要利用对完整 left-join 数据的访问并将 M:M 关联嵌入查询集中,以便它们可以在模板中重建,您需要两件事:

  • 调用 .distinct(join_key_list),提供使连接不同的主键
  • 添加对 .annotate(**mm_key_values) 的调用,该调用使用 M:M 相关的 table 主键值创建注释(通过 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. 使用server-side分页
  3. 在模板中显示 SQL left-join 的“真实”表示

请注意,我仍然使用 .prefetch_related()(此处未显示),我向其提供 Prefetch() 对象,其中包含提供给 queryset 选项的原始过滤器的副本,只有过滤器中提供的路径是“re-rooted”^^。这提供了显着的性能提升。

我还编写了一个名为 get_manytomany_rec 的模板标签 (simple_tag),它采用 M:M table 记录 (pg.compounds.all) 和注释字段值(例如 pg.compound)并检索该行的相应复合记录。我这样做的方式是,如果我将 M:M table 设置为 而不是 拆分结果行,它只是 returns 提供的 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 相关 table 显示为 1 行上的 ; 分隔值或使它将 1 行分成多行。)

喜欢 有一个 better/simpler 解决方案而不是这个 work-around,但是在有人提供更好的答案之前,这是我能做的最好的.