PAYATHON 2026

Doctrine DQL 如何按 phone 分组并返回对象数组

支付阿杰

结论

Doctrine DQL 无法把同一个 phone 对应的多条记录直接水合为嵌套结构,也就是以电话号码为键、以 Payment 对象数组为值的结果。GROUP_CONCAT() 是数据库的标量聚合函数,只能返回字符串等标量值,不能聚合 ORM 实体对象。

在不修改数据库结构的前提下,可以这样处理:

  1. 使用 DQL 查询完整的 Payment 对象;
  2. 按 phone 排序;
  3. 在 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 分组,就是最可靠的实现。

备注:内容仅供参考。