paysera/lib-pagination

使用基于游标或偏移量的分页,对 Doctrine QueryBuilder 进行分页

1.4.0 2023-11-27 13:07 UTC

This package is auto-updated.

Last update: 2024-08-27 14:57:48 UTC


README

Latest Version on Packagist Software License Build Status Coverage Status Quality Score Total Downloads

此组件允许使用基于游标的分页或偏移量分页来分页 Doctrine QueryBuilder 实例。

为什么?

性能

在大型数据集中,游标分页可以更有效率。

例如,为了获取包含 100 项的第 3000 页,使用偏移量你需要执行这样的查询

SELECT *
FROM items
WHERE field = 'some value'
ORDER BY created_at DESC
LIMIT 100 OFFSET 29900

即使你在你查询的字段(以及你排序的字段)上有索引,这种类型的查询也需要先遍历 29900 个项,然后才能给你所需的结果。

如果使用基于游标的分页,查询结果可能如下所示

SELECT *
FROM items
WHERE field = 'some value'
AND created_at <= '2018-01-01 00:01:02' AND id < 12345
ORDER BY created_at DESC, id DESC
LIMIT 100

这允许向表中添加所需的索引以启用快速查询。请记住,如果没有任何索引,性能可能非常相似。

在这个具体示例中,应该在 field,created_at,id 上有一个多列索引以获得最佳性能(或者如果 created_at 比较独特,则只需 field,created_at)。

遍历所有项

当使用基于偏移量的分页的查询时,可能会得到相同的项两次或错过一些项。

这是因为当我们获取页面时,可能会添加新的项或删除一些项,这会改变所有其他项的位置。

让我们举一个例子。我们有一些页面,每个页面有 5 个项。我们已经获取了包含项 1 2 3 4 5 的第一页。在我们获取第二页之前,可能会发生以下情况

Original:        1 2 3 4 5 6 7 8 9
Second page:               6 7 8 9

"0" is added:    0 1 2 3 4 5 6 7 8 9
Second page:               5 6 7 8 9     <- 5 is duplicated

"2" was removed: 1 3 4 5 6 7 8 9
Second page:               7 8 9     <- 6 was skipped

如果我们使用基于游标的分页,我们将从某个具体的项之后(包括或排除)或之前开始页面,所以我们不会遇到这些问题。

注意事项

使用游标,很容易移动到下一页和上一页,但无法

  • 知道你现在在第几页;
  • 直接跳过一些页面或转到页面 X。

为了支持混合情况

  • 游标总是提供并可用于分页;
  • hasNexthasPrevious 也总是可用,以便知道你是否在第一页或最后一页;
  • 偏移量可用于分页,但可能受到某些最大值的限制。这可以用于移动到第 N 页,但不能移动到 100N 页,这种情况并不常见;
  • 如果您需要跳到最后一个页面,您应该提供(或自动执行)反转排序并转到第一页;
  • 要显示页数,必须计算总计数。这可以完成,但默认情况下是禁用的,再次出于性能原因。您应该避免在每一页都提供总计数 - 而是在需要时或至少将其缓存。

安装

composer require paysera/lib-pagination

基本用法

使用 ResultProvider 类(服务)来提供结果。

要获取结果,需要两个参数

  • ConfiguredQuery。这是 QueryBuilder 的包装,包含可用的排序字段配置、最大偏移量、结果项转换以及是否计算总计数;
  • Pager。这包含用户提供的参数:偏移量或游标之后/之前、限制和排序方向。

通常 ConfiguredQuery 包含与 QueryBuilder 相关的内部细节,因此建议直接从 Repository 返回此内容 - 让我们保持实现细节在一起。

分页器应在输入层中的某个位置创建,通常在控制器或类似位置。

示例用法

<?php
use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery;
use Paysera\Pagination\Service\Doctrine\ResultProvider;
use Paysera\Pagination\Service\CursorBuilder;
use Paysera\Pagination\Service\Doctrine\QueryAnalyser;
use Paysera\Pagination\Entity\OrderingConfiguration;
use Paysera\Pagination\Entity\Pager;
use Paysera\Pagination\Entity\OrderingPair;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Doctrine\ORM\EntityManagerInterface;

$resultProvider = new ResultProvider(
    new QueryAnalyser(),
    new CursorBuilder(PropertyAccess::createPropertyAccessor())
);

/** @var EntityManagerInterface $entityManager */
$queryBuilder = $entityManager->createQueryBuilder()
    ->select('m')
    ->from('Bundle:MyEntity', 'm')
    ->andWhere('m.field = :param')
    ->setParameter('param', 'some value')
;

$configuredQuery = (new ConfiguredQuery($queryBuilder))
    ->addOrderingConfiguration(
        'my_field_name',
        (new OrderingConfiguration('m.field', 'field'))->setOrderAscending(true)
    )
    ->addOrderingConfiguration('my_other_name', new OrderingConfiguration('m.otherField', 'otherField'))
    ->setTotalCountNeeded(true) // total count will be returned only if this is called
    ->setMaximumOffset(100) // you can optionally limit maximum offset
    ->setItemTransformer(function ($item) {
        // return transformed item if needed
    })
;

$pager = (new Pager())
    ->setLimit(10)
    ->setOffset(123)    // set only one of offset, after or before
    ->setAfter('Cursor from Result::getNextCursor')
    ->setBefore('Cursor from Result::getPreviousCursor')
    ->addOrderBy(new OrderingPair('my_field_name')) // order by default direction (asc in this case)
    ->addOrderBy(new OrderingPair('my_other_name', true)) // or set direction here
;
$result = $resultProvider->getResultForQuery($configuredQuery, $pager);

$result->getItems(); // items in the page
$result->getNextCursor(); // value to pass with setAfter for next page
$result->getPreviousCursor(); // value to pass with setBefore for previous page
$result->getTotalCount(); // available only if setTotalCountNeeded(true) was called

$totalCount = $resultProvider->getTotalCountForQuery($configuredQuery); // calculate total count directly

使用迭代器

有一些类(服务)可以帮助迭代大结果集

  • ResultIterator - 使用分页进行迭代,隐藏分页;
  • FlushingResultIterator - 与 ResultIterator 相同,但在每一页之后刷新并清除 EntityManager。当您需要修改由 Doctrine 管理的实体时使用。这种模式(而不是仅调用 $em->flush())可以避免内存不足失败并优化进程。

使用 ResultIterator

<?php
use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery;
use Paysera\Pagination\Service\Doctrine\ResultIterator;
use Paysera\Pagination\Service\CursorBuilder;
use Paysera\Pagination\Service\Doctrine\QueryAnalyser;
use Paysera\Pagination\Service\Doctrine\ResultProvider;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Psr\Log\NullLogger;

/** @var ConfiguredQuery $configuredQuery */

$resultIterator = new ResultIterator(
    new ResultProvider(
        new QueryAnalyser(),
        new CursorBuilder(PropertyAccess::createPropertyAccessor())
    ),
    new NullLogger(),
    $defaultLimit = 1000
);

foreach ($this->resultIterator->iterate($configuredQuery) as $item) {
    // process $item where flush is not needed
    // for example, send ID or other data to working queue, process files etc.
}

使用 FlushingResultIterator

<?php
use Paysera\Pagination\Entity\Doctrine\ConfiguredQuery;
use Paysera\Pagination\Service\Doctrine\FlushingResultIterator;
use Paysera\Pagination\Service\CursorBuilder;
use Paysera\Pagination\Service\Doctrine\QueryAnalyser;
use Paysera\Pagination\Service\Doctrine\ResultProvider;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Psr\Log\NullLogger;
use Doctrine\ORM\EntityManagerInterface;

/** @var ConfiguredQuery $configuredQuery */
/** @var EntityManagerInterface $entityManager */

$resultIterator = new FlushingResultIterator(
    new ResultProvider(
        new QueryAnalyser(),
        new CursorBuilder(PropertyAccess::createPropertyAccessor())
    ),
    new NullLogger(),
    $defaultLimit = 1000,
    $entityManager
);

foreach ($this->resultIterator->iterate($configuredQuery) as $item) {
    // process $item or other entities where flush will be called after each page
    // for example:
    $item->setTitle(formatTitleFor($item));
    
    // keep in mind, that clear is also called – don't reuse other Entities outside of foreach cycle
}

echo "Updated successfully";
// no need to flush here anymore

如果出现内存不足异常等,请在日志(INFO 级别)中搜索最后一个“继续迭代”消息,查看上下文中的“after”,然后

$lastCursor = '"123"'; // get from logs
$startPager = (new Pager())
    ->setLimit(500) // can also override default limit
    ->setAfter($lastCursor)
;
foreach ($this->resultIterator->iterate($configuredQuery, $startPager) as $item) {
    // process $item
}

语义版本控制

此库遵循 语义版本控制

有关 API 中可以更改的内容和不能更改的内容的基本信息,请参阅 Symfony BC 规则

运行测试

composer update
composer test

贡献

请随意创建问题和发送拉取请求。

您可以使用此命令修复任何代码风格问题

composer fix-cs