diff --git a/CHANGELOG.md b/CHANGELOG.md index bce56ad..520fe36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * feat(`PageableInterface`): add `$start` parameter to `getPages()` method to ease batch resuming. * refactor: move common indexBy logic to separate package +* feat(`SelectableAdapter`): add `indexBy` parameter # 0.11.2 diff --git a/packages/rekapager-doctrine-collections-adapter/composer.json b/packages/rekapager-doctrine-collections-adapter/composer.json index 4d0d1cb..bfba184 100644 --- a/packages/rekapager-doctrine-collections-adapter/composer.json +++ b/packages/rekapager-doctrine-collections-adapter/composer.json @@ -29,6 +29,7 @@ "require": { "php": "^8.2", "doctrine/collections": "^2.2", + "rekalogika/rekapager-adapter-common": "self.version", "rekalogika/rekapager-keyset-pagination": "^0.11.2", "rekalogika/rekapager-offset-pagination": "^0.11.2" } diff --git a/packages/rekapager-doctrine-collections-adapter/src/SelectableAdapter.php b/packages/rekapager-doctrine-collections-adapter/src/SelectableAdapter.php index a1b0b09..0f1c199 100644 --- a/packages/rekapager-doctrine-collections-adapter/src/SelectableAdapter.php +++ b/packages/rekapager-doctrine-collections-adapter/src/SelectableAdapter.php @@ -16,6 +16,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Order; use Doctrine\Common\Collections\Selectable; +use Rekalogika\Rekapager\Adapter\Common\IndexResolver; use Rekalogika\Rekapager\Doctrine\Collections\Exception\UnsupportedCollectionItemException; use Rekalogika\Rekapager\Doctrine\Collections\Exception\UnsupportedCriteriaException; use Rekalogika\Rekapager\Doctrine\Collections\Internal\SelectableKeysetItem; @@ -41,6 +42,7 @@ final class SelectableAdapter implements public function __construct( private readonly Selectable $collection, ?Criteria $criteria = null, + private readonly string|null $indexBy = null, ) { $criteria ??= Criteria::create(); $orderings = $criteria->orderings(); @@ -79,7 +81,25 @@ public function getOffsetItems(int $offset, int $limit): array ->setMaxResults($limit); try { - return $this->collection->matching($criteria)->toArray(); + // @todo: does not preserve keys due to a longstanding Doctrine bug + // https://github.com/doctrine/orm/issues/4693 + // workaround: use indexBy + $items = $this->collection->matching($criteria)->toArray(); + + if ($this->indexBy !== null && array_is_list($items)) { + $newItems = []; + + /** @var T $item */ + foreach ($items as $item) { + $key = IndexResolver::resolveIndex($item, $this->indexBy); + $newItems[$key] = $item; + } + + /** @var array */ + $items = $newItems; + } + + return $items; } catch (\TypeError $e) { if (preg_match('|ClosureExpressionVisitor::getObjectFieldValue\(\): Argument \#1 \(\$object\) must be of type object\|array, (\S+) given|', $e->getMessage(), $matches)) { throw new UnsupportedCollectionItemException($matches[1], $e); @@ -271,7 +291,23 @@ public function getKeysetItems( $criteria = $this->getCriteria($offset, $limit, $boundaryValues, $boundaryType); try { + // @todo: does not preserve keys due to a longstanding Doctrine bug + // https://github.com/doctrine/orm/issues/4693 + // workaround: use indexBy $items = $this->collection->matching($criteria)->toArray(); + + if ($this->indexBy !== null && array_is_list($items)) { + $newItems = []; + + /** @var T $item */ + foreach ($items as $item) { + $key = IndexResolver::resolveIndex($item, $this->indexBy); + $newItems[$key] = $item; + } + + /** @var array */ + $items = $newItems; + } } catch (\TypeError $e) { if (preg_match('|ClosureExpressionVisitor::getObjectFieldValue\(\): Argument \#1 \(\$object\) must be of type object\|array, (\S+) given|', $e->getMessage(), $matches)) { throw new UnsupportedCollectionItemException($matches[1], $e); @@ -281,7 +317,7 @@ public function getKeysetItems( } if ($boundaryType === BoundaryType::Upper) { - $items = array_reverse($items); + $items = array_reverse($items, true); } $properties = array_keys($this->criteria->orderings()); diff --git a/tests/src/App/Entity/User.php b/tests/src/App/Entity/User.php index cff3839..40e7b48 100644 --- a/tests/src/App/Entity/User.php +++ b/tests/src/App/Entity/User.php @@ -35,7 +35,7 @@ class User /** * @var Collection */ - #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'user', fetch: 'EXTRA_LAZY')] + #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'user', fetch: 'EXTRA_LAZY', indexBy: 'id')] private Collection $posts; public function __construct() diff --git a/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterCollection.php b/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterCollection.php index 07b5416..6f1bf0b 100644 --- a/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterCollection.php +++ b/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterCollection.php @@ -66,6 +66,7 @@ public function generatePageable( $adapter = new SelectableAdapter( collection: $selectable, criteria: $criteria, + indexBy: 'id', ); $pageable = new KeysetPageable( diff --git a/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterEntityRepository.php b/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterEntityRepository.php index e40c346..e56dc42 100644 --- a/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterEntityRepository.php +++ b/tests/src/App/PageableGenerator/KeysetPageableSelectableAdapterEntityRepository.php @@ -58,7 +58,11 @@ public function generatePageable( 'id' => Order::Ascending ]); - $adapter = new SelectableAdapter($selectable, $criteria); + $adapter = new SelectableAdapter( + collection: $selectable, + criteria: $criteria, + indexBy: 'id', + ); $pageable = new KeysetPageable( adapter: $adapter, diff --git a/tests/src/App/PageableGenerator/OffsetPageableSelectableAdapterCollection.php b/tests/src/App/PageableGenerator/OffsetPageableSelectableAdapterCollection.php index 81c2e5e..ccbdc85 100644 --- a/tests/src/App/PageableGenerator/OffsetPageableSelectableAdapterCollection.php +++ b/tests/src/App/PageableGenerator/OffsetPageableSelectableAdapterCollection.php @@ -63,7 +63,11 @@ public function generatePageable( 'id' => Order::Ascending ]); - $adapter = new SelectableAdapter($selectable, $criteria); + $adapter = new SelectableAdapter( + collection: $selectable, + criteria: $criteria, + indexBy: 'id', + ); $pageable = new OffsetPageable( adapter: $adapter, diff --git a/tests/src/IntegrationTests/Index/SelectableAdapterIndexByTest.php b/tests/src/IntegrationTests/Index/SelectableAdapterIndexByTest.php new file mode 100644 index 0000000..9c64ea0 --- /dev/null +++ b/tests/src/IntegrationTests/Index/SelectableAdapterIndexByTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Rekapager\Tests\IntegrationTests\Index; + +use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Order; +use Rekalogika\Rekapager\Adapter\Common\Exception\IncompatibleIndexTypeException; +use Rekalogika\Rekapager\Doctrine\Collections\SelectableAdapter; +use Rekalogika\Rekapager\Keyset\KeysetPageable; +use Rekalogika\Rekapager\Tests\App\Entity\Post; +use Rekalogika\Rekapager\Tests\App\Repository\PostRepository; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class SelectableAdapterIndexByTest extends KernelTestCase +{ + protected function getSelectable(): PostRepository + { + return self::getContainer()->get(PostRepository::class); + } + + public function testIndexBy(): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('setName', 'large')) + ->orderBy([ + 'date' => Order::Descending, + 'title' => Order::Ascending, + 'id' => Order::Ascending + ]); + + $adapter = new SelectableAdapter( + collection: $this->getSelectable(), + criteria: $criteria, + indexBy: 'id' + ); + + $pageable = new KeysetPageable( + adapter: $adapter, + itemsPerPage: 1000, + ); + + /** @var Post $post */ + foreach ($pageable->getFirstPage() as $key => $post) { + static::assertInstanceOf(Post::class, $post); + static::assertEquals($key, $post->getId()); + } + } + + public function testInvalidIndexBy(): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('setName', 'large')) + ->orderBy([ + 'date' => Order::Descending, + 'title' => Order::Ascending, + 'id' => Order::Ascending + ]); + + $adapter = new SelectableAdapter( + collection: $this->getSelectable(), + criteria: $criteria, + indexBy: 'foo' + ); + + $pageable = new KeysetPageable( + adapter: $adapter, + itemsPerPage: 1000, + ); + + $this->expectException(IncompatibleIndexTypeException::class); + + iterator_to_array($pageable->getFirstPage()); + } +}