
本文详解如何使用 doctrine querybuilder 对自引用实体的子集合进行聚合计算(如 sum),实现“已完全卖出”或“部分卖出”交易的精准筛选,并规避浮点精度陷阱与 n+1 查询问题。
本文详解如何使用 doctrine querybuilder 对自引用实体的子集合进行聚合计算(如 sum),实现“已完全卖出”或“部分卖出”交易的精准筛选,并规避浮点精度陷阱与 n+1 查询问题。
在金融类应用中,常见一种层级化交易建模:买入交易(parent)作为根节点,后续多次卖出交易(children)通过 parent 关系关联至该买入记录。业务上需区分三种状态:未卖出(无 children)、部分卖出(children 总执行量 完全卖出(children 总执行量 = parent 执行量)。Doctrine 原生不支持直接对 OneToMany 关系字段做跨行聚合,必须借助 SQL JOIN + GROUP BY + HAVING 实现。
✅ 正确实现:使用 INNER JOIN + GROUP BY + HAVING
以下 findSoldTrades() 方法返回所有 已完全卖出 的买入交易(即子交易 executed 总和等于父交易 executed):
// src/Repository/TradeRepository.php
public function findSoldTrades(): array
{
return $this->createQueryBuilder('t')
->innerJoin(Trade::class, 's', 'WITH', 's.parent = t')
->groupBy('t.id')
->having('SUM(s.executed) = t.executed')
->orderBy('t.created', 'ASC')
->getQuery()
->getResult();
}
? 关键说明:
- innerJoin(Trade::class, 's', 'WITH', 's.parent = t'):显式 JOIN 自身表,别名 s 表示子交易;WITH 子句确保关联条件基于对象关系(非硬编码外键),兼容 Doctrine 映射。
- groupBy('t.id'):按父交易分组,使 SUM(s.executed) 能正确聚合每个父交易下的全部子交易。
- having(...):在分组后过滤,不可用 where(WHERE 在分组前执行,无法访问聚合结果)。
- 返回类型为 array(默认 getResult()),若需单对象可改用 getOneOrNullResult()(需确保逻辑唯一性)。
⚠️ 注意事项与最佳实践
-
数据类型必须使用 DECIMAL
原问题中 price 和 executed 字段使用 float 类型会导致浮点精度误差(如 0.1 + 0.2 !== 0.3),引发聚合判断失败。务必改为 DECIMAL:#[ORM\Column(type: 'decimal', precision: 12, scale: 4)] private $executed;
对应数据库字段为 DECIMAL(12,4),确保金额运算精确。
-
区分“完全卖出”与“部分卖出” 若需同时获取两类交易,可拆分为两个方法,或用单查询返回状态标识:
public function findPartiallyOrFullySoldTrades(): array { return $this->createQueryBuilder('t') ->innerJoin(Trade::class, 's', 'WITH', 's.parent = t') ->groupBy('t.id') ->having('SUM(s.executed) > 0') // 至少有一个子交易 ->addSelect('SUM(s.executed) AS HIDDEN totalSold') ->addSelect("CASE WHEN SUM(s.executed) = t.executed THEN 'full' ELSE 'partial' END AS HIDDEN status") ->orderBy('t.created', 'ASC') ->getQuery() ->getResult(); } -
性能优化建议
- 为 parent_id 字段添加数据库索引(Doctrine 默认不自动创建):
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true, name: 'parent_id')] private $parent;
并在迁移中手动添加索引:
CREATE INDEX IDX_TRADE_PARENT_ID ON trade (parent_id);
- 避免 fetch: 'EAGER'(已在实体中声明)——它会强制加载所有子交易,导致 N+1 和内存浪费;聚合查询应完全由数据库完成,无需加载关联对象。
- 为 parent_id 字段添加数据库索引(Doctrine 默认不自动创建):
-
替代方案对比
- ❌ 子查询(如原问题中的嵌套 SELECT):可读性差、难以维护、Doctrine 不易映射,且性能通常低于 JOIN。
- ❌ PHP 层循环计算:需先查出所有父交易再遍历子交易,网络与内存开销巨大,违背 ORM “以数据为中心”的设计哲学。
- ✅ 推荐:始终将聚合逻辑下推至数据库,利用其优化器与索引能力。
通过以上实现,你不仅能精准识别卖出状态,更构建了可扩展的层级聚合模式——类似场景(如订单退款汇总、任务子项进度统计)均可复用此 JOIN-GROUP-HAVING 范式。











