返回

Symfony 多对多关联表 NOT IN 查询陷阱及解决

mysql

Symfony 中多对多关联表的 NOT IN 查询陷阱

在使用 Symfony 的 Doctrine ORM 处理多对多关系时,特别是需要使用 NOT IN 进行过滤时,可能会遇到一些意料之外的问题。例如,在左连接查询中,期望返回所有 Page 实体,并排除具有特定 Term 关联的 Page 实体,但是查询结果可能会意外地将没有 Term 关联的 Page 实体也过滤掉。 这篇文章会分析这个问题,并给出对应的解决思路。

问题根源:多对多左连接的 NOT IN 条件

在关系型数据库中,多对多关系通常通过一个连接表来实现。在使用 Doctrine 构建查询时,一个常用的方法是利用 leftJoin 实现 “获取所有 page,同时包含相关的 term信息”, 也就是获取Page数据,同时把对应的term关联到每一行page。但是,当应用 NOT IN 条件,试图排除关联到某些特定 termpage时,可能会产生出乎意料的结果。原因在于当一个 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_.idNULLpage都排除在外。而这些 page 恰好是没有 term 的。 这种结果不是预期的。 核心问题是,NOT IN 条件不适合直接用在可能为 null 的列上,从而无法实现“包含所有没有对应项”的目标。

解决方案一:结合 IS NULLNOT 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_.idNULL的情况。

对应的 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 的语义非常重要。上述方法提供了两种常见的解决方案,能够解决由 leftJoinNOT IN 组合导致的特定问题,并保证正确返回所需结果。