Skip to content

Commit 6ebf12b

Browse files
committed
feat(orm): Add api to do cursor based pagination
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent fa8cc98 commit 6ebf12b

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

lib/public/AppFramework/ORM/Repository.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,9 @@ public function insertOrUpdate(object $entity): object {
397397
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
398398
* @param array<string, \SortDirection> $orderBy
399399
* @return \Generator<T>
400+
*
401+
* @note If you need to implement pagination, prefer using findByAfterId instead.
402+
*
400403
* @since 35.0.0
401404
*/
402405
public function findBy(array $criteria, array $orderBy = [], ?int $limit = null, ?int $offset = null): \Generator {
@@ -413,6 +416,78 @@ public function findBy(array $criteria, array $orderBy = [], ?int $limit = null,
413416
return $this->yieldJoinedEntities($qb, $relations);
414417
}
415418

419+
/**
420+
* Finds entities by a set of criteria, keyed by property name, one page at a time ordered by
421+
* their primary key — using keyset (seek) pagination instead of OFFSET/LIMIT.
422+
*
423+
* Unlike findBy()'s $offset, which forces the database to scan and discard every preceding
424+
* row on every call, $lastId lets it seek straight to the right spot through the primary
425+
* key's index, so each page costs the same regardless of how deep it is. Pass null to fetch
426+
* the first page, then the id of the last entity returned to fetch the next one; stop once
427+
* fewer than $limit entities come back.
428+
*
429+
* @warning This does not support tables with composite primary keys
430+
*
431+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
432+
* @param int|string|null $lastId The primary key of the last entity from the previous
433+
* page, or null to fetch the first page.
434+
* @return \Generator<T>
435+
* @throws \LogicException if the entity has a composite primary key
436+
* @since 35.0.0
437+
*/
438+
public function findByAfterId(array $criteria, int|float|string|null $lastId, int $limit): \Generator {
439+
$entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass());
440+
$idColumn = $entityInfo->mappingPropertyToColumn[$entityInfo->getSingleIdProperty()->getName()];
441+
442+
[$qb, $relations] = $this->getJoinedSelectQueryBuilder($criteria);
443+
444+
if ($lastId !== null) {
445+
$type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$idColumn], false);
446+
$qb->andWhere($qb->expr()->gt('e.' . $idColumn, $qb->createNamedParameter($lastId, $type)));
447+
}
448+
449+
$qb->orderBy('e.' . $idColumn, \SortDirection::Ascending);
450+
$qb->setMaxResults($limit);
451+
452+
return $this->yieldJoinedEntities($qb, $relations);
453+
}
454+
455+
/**
456+
* Finds entities by a set of criteria, keyed by property name, one page at a time ordered by
457+
* their primary key in descending order — using keyset (seek) pagination instead of
458+
* OFFSET/LIMIT.
459+
*
460+
* This is the mirror image of findByAfterId(), walking from the highest id downwards instead
461+
* of from the lowest id upwards. Pass null to fetch the first page (starting from the highest
462+
* id), then the id of the last entity returned to fetch the next one; stop once fewer than
463+
* $limit entities come back.
464+
*
465+
* @warning This does not support tables with composite primary keys
466+
*
467+
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
468+
* @param int|string|null $lastId The primary key of the last entity from the previous
469+
* page, or null to fetch the first page.
470+
* @return \Generator<T>
471+
* @throws \LogicException if the entity has a composite primary key
472+
* @since 35.0.0
473+
*/
474+
public function findByBeforeId(array $criteria, int|float|string|null $lastId, int $limit): \Generator {
475+
$entityInfo = $this->entityManager->getEntityInfo($this->getEntityClass());
476+
$idColumn = $entityInfo->mappingPropertyToColumn[$entityInfo->getSingleIdProperty()->getName()];
477+
478+
[$qb, $relations] = $this->getJoinedSelectQueryBuilder($criteria);
479+
480+
if ($lastId !== null) {
481+
$type = $this->entityManager->getParameterType($entityInfo->mappingColumnToTypes[$idColumn], false);
482+
$qb->andWhere($qb->expr()->lt('e.' . $idColumn, $qb->createNamedParameter($lastId, $type)));
483+
}
484+
485+
$qb->orderBy('e.' . $idColumn, \SortDirection::Descending);
486+
$qb->setMaxResults($limit);
487+
488+
return $this->yieldJoinedEntities($qb, $relations);
489+
}
490+
416491
/**
417492
* @param array<string, int|float|string|null|\DateTime|\BackedEnum|list<int|float|string|\BackedEnum>> $criteria
418493
* @return int The number of rows deleted

tests/lib/AppFramework/ORM/RepositoryTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,90 @@ public function testDeleteBy(): void {
404404
$this->assertCount(0, $entities);
405405
}
406406

407+
public function testFindByAfterId(): void {
408+
$repo = $this->getRepository(PrimaryKey::class);
409+
410+
$entities = [];
411+
for ($i = 0; $i < 5; $i++) {
412+
$entity = new PrimaryKey();
413+
$entity->name = 'entry' . $i;
414+
$entity->notNullable = 'testFindByAfterId';
415+
$entity->integer = $i;
416+
$entity->bigInt = $i;
417+
$entity->float = 1.0;
418+
$entity->date = new \DateTime('now');
419+
$repo->insert($entity);
420+
$entities[] = $entity;
421+
}
422+
423+
// Walk through every page, seeking from the last id of the previous one, until a page
424+
// comes back smaller than the requested limit.
425+
$seenIds = [];
426+
$lastId = null;
427+
do {
428+
$page = iterator_to_array($repo->findByAfterId(['notNullable' => 'testFindByAfterId'], $lastId, 2));
429+
foreach ($page as $entity) {
430+
$seenIds[] = $entity->id;
431+
$lastId = $entity->id;
432+
}
433+
} while (count($page) === 2);
434+
435+
$this->assertEquals(array_map(static fn (PrimaryKey $entity): ?int => $entity->id, $entities), $seenIds);
436+
437+
foreach ($entities as $entity) {
438+
$repo->delete($entity);
439+
}
440+
}
441+
442+
public function testFindByAfterIdWithCompositePrimaryKeyThrows(): void {
443+
$repo = $this->getRepository(CompositeKey::class);
444+
445+
$this->expectException(\LogicException::class);
446+
iterator_to_array($repo->findByAfterId([], null, 10));
447+
}
448+
449+
public function testFindByBeforeId(): void {
450+
$repo = $this->getRepository(PrimaryKey::class);
451+
452+
$entities = [];
453+
for ($i = 0; $i < 5; $i++) {
454+
$entity = new PrimaryKey();
455+
$entity->name = 'entry' . $i;
456+
$entity->notNullable = 'testFindByBeforeId';
457+
$entity->integer = $i;
458+
$entity->bigInt = $i;
459+
$entity->float = 1.0;
460+
$entity->date = new \DateTime('now');
461+
$repo->insert($entity);
462+
$entities[] = $entity;
463+
}
464+
465+
// Walk through every page, seeking from the last id of the previous one, until a page
466+
// comes back smaller than the requested limit.
467+
$seenIds = [];
468+
$lastId = null;
469+
do {
470+
$page = iterator_to_array($repo->findByBeforeId(['notNullable' => 'testFindByBeforeId'], $lastId, 2));
471+
foreach ($page as $entity) {
472+
$seenIds[] = $entity->id;
473+
$lastId = $entity->id;
474+
}
475+
} while (count($page) === 2);
476+
477+
$this->assertEquals(array_reverse(array_map(static fn (PrimaryKey $entity): ?int => $entity->id, $entities)), $seenIds);
478+
479+
foreach ($entities as $entity) {
480+
$repo->delete($entity);
481+
}
482+
}
483+
484+
public function testFindByBeforeIdWithCompositePrimaryKeyThrows(): void {
485+
$repo = $this->getRepository(CompositeKey::class);
486+
487+
$this->expectException(\LogicException::class);
488+
iterator_to_array($repo->findByBeforeId([], null, 10));
489+
}
490+
407491
public function testCompositePrimaryKey(): void {
408492
$repo = $this->getRepository(CompositeKey::class);
409493
$this->assertEquals('repository_composite_key', $repo->getTableName());

0 commit comments

Comments
 (0)