Symfony 多对多关联表 NOT IN 查询陷阱及解决
2025-01-27 10:41:09
Symfony 中多对多关联表的 NOT IN
查询陷阱
在使用 Symfony 的 Doctrine ORM 处理多对多关系时,特别是需要使用 NOT IN
进行过滤时,可能会遇到一些意料之外的问题。例如,在左连接查询中,期望返回所有 Page 实体,并排除具有特定 Term 关联的 Page 实体,但是查询结果可能会意外地将没有 Term 关联的 Page 实体也过滤掉。 这篇文章会分析这个问题,并给出对应的解决思路。
问题根源:多对多左连接的 NOT IN
条件
在关系型数据库中,多对多关系通常通过一个连接表来实现。在使用 Doctrine 构建查询时,一个常用的方法是利用 leftJoin
实现 “获取所有 page
,同时包含相关的 term
信息”, 也就是获取Page数据,同时把对应的term关联到每一行page。但是,当应用 NOT IN
条件,试图排除关联到某些特定 term
的 page
时,可能会产生出乎意料的结果。原因在于当一个 page
没有任何 term
关联时,左连接返回的结果是对应 term
的属性是 null
。 此时直接对 tt.id
采用 NOT IN
条件过滤,无法匹配 null
。因此,无任何关联的page
也就被过滤掉了。 数据库本身的行为就导致了这个结果的出现,并非是 doctrine 或者 symfony 存在的问题。
分析上述生成的 SQL 语句:
SELECT p0_.*
FROM page p0_ LEFT JOIN page_term p2_ ON p0_.id = p2_.page_id
LEFT JOIN term t1_ ON t1_.id = p2_.term_id
WHERE t1_.id NOT IN (--the array here--)
ORDER BY p0_.`publishDate`
DESC LIMIT 15 OFFSET 0
可以明显看到,WHERE t1_.id NOT IN ...
这个条件, 会将任何t1_.id
为 NULL
的 page
都排除在外。而这些 page
恰好是没有 term
的。 这种结果不是预期的。 核心问题是,NOT IN
条件不适合直接用在可能为 null
的列上,从而无法实现“包含所有没有对应项”的目标。
解决方案一:结合 IS NULL
和 NOT IN
为了修正这个问题,需要将 “无 term 关联” 的情况考虑进来。 这可以通过添加一个 OR tt.id IS NULL
条件来实现。修改后的 SQL 类似如下:
SELECT p0_.*
FROM page p0_ LEFT JOIN page_term p2_ ON p0_.id = p2_.page_id
LEFT JOIN term t1_ ON t1_.id = p2_.term_id
WHERE (t1_.id NOT IN (--the array here--) OR t1_.id IS NULL)
ORDER BY p0_.`publishDate`
DESC LIMIT 15 OFFSET 0
修改后的查询语句能正确处理两种情况: term
id 不在指定的列表内, 以及 page 没有关联 term,也就是t1_.id
为 NULL
的情况。
对应的 Query Builder 代码如下:
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('p')
->from('AppBundle:Page', 'p')
->leftJoin('p.terms','tt');
if(!empty($limit))
$qb->setMaxResults($limit);
if (!empty($ignoreTerms)) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->notIn('tt.id', $ignoreTerms),
$qb->expr()->isNull('tt.id')
)
);
}
return $qb;
这种方式简洁有效, 直接通过表达式来修改原有的 SQL逻辑,让查询能够返回符合预期的结果。
解决方案二: 使用子查询 NOT EXISTS
另一种方式是采用子查询实现。使用 NOT EXISTS
可以表达 “不存在关联到指定 Term 的 Page” 这个概念。子查询首先查找到要排除的 Page ID, 然后主查询只返回不存在于这个列表中的 Page 实体,实现相同目标。 这样避免了 NULL
值问题, 并使语义更加明确。
SQL 示例:
SELECT p0_.* FROM page p0_
WHERE NOT EXISTS (
SELECT 1 FROM page_term p2_ INNER JOIN term t1_ ON t1_.id = p2_.term_id WHERE p2_.page_id = p0_.id AND t1_.id IN (--the array here--)
)
ORDER BY p0_.`publishDate` DESC
LIMIT 15 OFFSET 0
对应的 Doctrine Query Builder 代码如下:
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('p')
->from('AppBundle:Page', 'p');
if (!empty($ignoreTerms)) {
$subQuery = $this->getEntityManager()->createQueryBuilder();
$subQuery->select('1')
->from('AppBundle:PageTerm', 'pt')
->innerJoin('AppBundle:Term', 't', 'WITH', 't.id = pt.term')
->where('pt.page = p.id')
->andWhere($subQuery->expr()->in('t.id', $ignoreTerms));
$qb->andWhere($qb->expr()->not($qb->expr()->exists($subQuery->getDQL())));
}
if(!empty($limit))
$qb->setMaxResults($limit);
return $qb;
使用 NOT EXISTS
可以更清楚地表达筛选意图。并且将逻辑分离,更加易于阅读和理解。这种方法避免了左连接和 NULL
值处理。子查询的方式将筛选条件放在了子查询层面,主查询只需判断是否存在结果,让筛选条件表达更清晰易懂。
最佳实践和安全提示
-
选择最合适的方案 : 第一种方案(
OR IS NULL
) 通常更简洁。但当筛选逻辑变得复杂时,第二种方案(使用子查询NOT EXISTS
)可能会更具优势, 保持SQL的可读性和可维护性。 根据具体的场景和团队代码风格,可以选择最适合的。 -
避免硬编码参数 : 使用参数化查询避免 SQL 注入。在上述示例中,Doctrine ORM 会自动处理参数绑定。需要确保
ignoreTerms
中的数据已经经过必要的校验和清理。 -
考虑性能 :
NOT IN
对于大数据集可能会效率较低,可以使用索引加速查询,也可以考虑数据结构上的优化(如果允许),以提升查询性能。同时使用EXISTS
的方案理论上会有更好的查询效率。 实际选择可以根据具体数据量以及执行效果决定。
在 Symfony 中处理多对多关联表的复杂查询时,仔细考虑 SQL 的语义非常重要。上述方法提供了两种常见的解决方案,能够解决由 leftJoin
与 NOT IN
组合导致的特定问题,并保证正确返回所需结果。