Doctrine DQL 如何按 phone 分组并返回对象数组
结论
Doctrine DQL 无法把同一个 phone 对应的多条记录直接水合为嵌套结构,也就是以电话号码为键、以 Payment 对象数组为值的结果。GROUP_CONCAT() 是数据库的标量聚合函数,只能返回字符串等标量值,不能聚合 ORM 实体对象。
在不修改数据库结构的前提下,可以这样处理:
- 使用 DQL 查询完整的
Payment对象; - 按
phone排序; - 在 PHP 中遍历并分组。
这是 ORM 场景中常见的处理方式,并不是低效的临时方案。
查询 Payment 对象
查询不需要使用 GROUP BY。数据库分组会把多行折叠成一行,这与保留每个 Payment 对象的目标冲突。
$dql = '
SELECT p
FROM AppBundle:Payment p
WHERE p.status = :status
ORDER BY p.phone ASC
';
$payments = $entityManager
->createQuery($dql)
->setParameter('status', $status)
->getResult();
查询返回普通的 Payment[],然后按电话号码建立关联数组:
$groupedPayments = array();
foreach ($payments as $payment) {
$phone = $payment->getPhone();
if (!isset($groupedPayments[$phone])) {
$groupedPayments[$phone] = array();
}
$groupedPayments[$phone][] = $payment;
}
得到的结构如下:
array(
'123456789' => array(
$payment1,
$payment2,
),
'234567891' => array(
$payment3,
),
);
数组中的元素仍是由 Doctrine 管理的 Payment 实体,可以正常调用方法或修改属性:
foreach ($groupedPayments['123456789'] as $payment) {
$payment->setStatus('processed');
}
需要保存修改时,最后调用:
$entityManager->flush();
封装到 Repository 中
如果多处业务都需要这种结果,可以把分组逻辑放到 Repository 中,避免在控制器或服务里重复编写:
public function findGroupedByPhone($status)
{
$payments = $this->createQueryBuilder('p')
->andWhere('p.status = :status')
->setParameter('status', $status)
->orderBy('p.phone', 'ASC')
->getQuery()
->getResult();
$result = array();
foreach ($payments as $payment) {
$phone = $payment->getPhone();
if (!isset($result[$phone])) {
$result[$phone] = array();
}
$result[$phone][] = $payment;
}
return $result;
}
调用方式如下:
$groupedPayments = $entityManager
->getRepository('AppBundle:Payment')
->findGroupedByPhone($status);
分组的时间复杂度是 O(n)。既然最终需要返回并处理所有 Payment 对象,每条记录就至少要访问一次,数据库聚合无法省去这一步。
为什么不能使用 GROUP_CONCAT
下面这种查询不适合这个需求:
SELECT GROUP_CONCAT(p)
FROM payments p
GROUP BY p.phone
即使实体定义了 __toString(),数据库接收并返回的依然是字符串,而不是保留 Doctrine 状态的实体对象。字符串结果不具备以下内容:
- 实体方法和属性;
- Doctrine Identity Map 中的对象身份;
- 关联关系和延迟加载能力;
- 实体变更跟踪。
也可以先用 GROUP_CONCAT() 拼接实体 ID,再通过第二次查询取回对象,但这会带来字符串解析、额外查询和数据库方言依赖,通常比直接查询后分组更复杂。
INDEX BY 也不能解决重复 phone
DQL 支持 INDEX BY,例如:
SELECT p
FROM AppBundle:Payment p
INDEX BY p.phone
不过,它只适用于索引值唯一的情况。当多个 Payment 使用同一个 phone 时,INDEX BY 不会自动在该键下创建对象数组,重复键还可能使结果被覆盖。因此,它不能用于一对多分组。
数据量较大时的处理
结果量很大时,更需要关注的是一次性水合全部实体带来的内存占用,而不是 PHP 中的分组循环。此时可以分批查询,或者使用 Doctrine 提供的迭代方式逐条处理。具体使用哪个 API,取决于 Doctrine ORM 的版本。
查询已经按 phone 排序,因此也可以边读取边处理,不必长期保存所有分组:
$currentPhone = null;
$currentPayments = array();
foreach ($payments as $payment) {
$phone = $payment->getPhone();
if ($currentPhone !== null && $phone !== $currentPhone) {
processPayments($currentPhone, $currentPayments);
$currentPayments = array();
}
$currentPhone = $phone;
$currentPayments[] = $payment;
}
if ($currentPhone !== null) {
processPayments($currentPhone, $currentPayments);
}
如果调用方必须一次性获得完整的关联数组,所有对象仍要驻留在内存中。这是结果结构本身带来的成本。
可以修改数据模型时
如果 phone 代表用户或付款人的身份,数据模型通常不该使用 M
- 一个
User或Customer对应多个Payment; - 每个
Payment属于一个User或Customer; - 也就是
User 1:N Payment。
这样便可以通过实体关联直接访问付款记录:
$user->getPayments();
对应的概念映射如下:
class User
{
/**
* @OneToMany(targetEntity="Payment", mappedBy="user")
*/
private $payments;
}
class Payment
{
/**
* @ManyToOne(targetEntity="User", inversedBy="payments")
*/
private $user;
}
只有同一笔付款可以同时属于多个用户时,M
才更合适。一般情况下,一条付款记录只有一个付款主体,因此通常使用 1。电话号码本身也不适合作为稳定的实体标识。电话号码可能发生变更,也可能重复、为空或格式不统一。更稳妥的方式是使用独立主键建立关联,并将 phone 保留为普通业务字段。
如果现有表结构无法调整,直接查询 Payment 实体,再在 PHP 中按 phone 分组,就是最可靠的实现。
备注:内容仅供参考。
