Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Pagination/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 3.7.0

- Support the Symfony UID types (`uuid`, `ulid`) as Doctrine ORM cursor fields and cursor context parameters

## 3.5.1

- Add missing support for Symfony 8.2's standalone `AssetMapperBundle`
Expand Down
2 changes: 2 additions & 0 deletions src/Pagination/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@
"symfony/asset-mapper": "^7.4|^8.0",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/css-selector": "^7.4|^8.0",
"symfony/doctrine-bridge": "^7.4|^8.0",
"symfony/dom-crawler": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/uid": "^7.4|^8.0",
"symfony/ux-live-component": "^3.0",
"symfony/ux-twig-component": "^3.0"
},
Expand Down
5 changes: 4 additions & 1 deletion src/Pagination/doc/doctrine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ Cursor fields must be non-nullable mapped scalar fields on the root
entity. Associations, unmapped aliases, nullable fields and invalid field
names are rejected against ORM metadata. Supported Doctrine types include
small, regular and big integers, string, GUID, float, decimal, boolean,
``datetime`` and ``datetimetz`` fields.
``datetime`` and ``datetimetz`` fields, and the ``uuid`` and ``ulid`` types
of ``symfony/doctrine-bridge``. A UID is compared in its database storage
format, which follows creation time for ULIDs and UUIDv7 (a UUIDv4 order is
deterministic but arbitrary).

The adapter appends every missing entity identifier field to make the
effective order deterministic. The direction passed to ``orderBy()``
Expand Down
7 changes: 6 additions & 1 deletion src/Pagination/src/Adapter/CursorValuesTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\UX\Pagination\Adapter;

use Symfony\Component\Uid\AbstractUid;
use Symfony\UX\Pagination\Exception\InvalidArgumentException;
use Symfony\UX\Pagination\Exception\RuntimeException;

Expand Down Expand Up @@ -87,11 +88,15 @@ private function normalizeCursorValue(mixed $value): int|string|float
->format('Y-m-d\TH:i:s.u\Z');
}

if ($value instanceof AbstractUid) {
return $value->toString();
}

if (\is_int($value) || \is_string($value) || \is_float($value)) {
return $value;
}

throw new RuntimeException(\sprintf('Cursor values must be scalar or \DateTimeInterface, got "%s".', get_debug_type($value)));
throw new RuntimeException(\sprintf('Cursor values must be scalar, \DateTimeInterface or a Symfony UID, got "%s".', get_debug_type($value)));
}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/Pagination/src/Adapter/DoctrineOrmAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@

namespace Symfony\UX\Pagination\Adapter;

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
use Symfony\Component\Uid\AbstractUid;
use Symfony\UX\Pagination\Cursor\CursorBoundary;
use Symfony\UX\Pagination\Cursor\CursorOrder;
use Symfony\UX\Pagination\Cursor\CursorSlice;
Expand Down Expand Up @@ -302,6 +305,19 @@ private function normalizeDoctrineCursorParameter(QueryBuilder $queryBuilder, st
{
$entity = $queryBuilder->getRootEntities()[0];
$type = $queryBuilder->getEntityManager()->getClassMetadata($entity)->getTypeOfField($field);

// Symfony UID types (uuid, ulid): the cursor carries the string form, bound back as a UID
// so the database compares it in its own storage format.
if (null !== $type && Type::hasType($type) && Type::getType($type) instanceof AbstractUidType) {
try {
$value = Type::getType($type)->convertToPHPValue((string) $value, $queryBuilder->getEntityManager()->getConnection()->getDatabasePlatform());
} catch (\Exception $exception) {
throw new InvalidArgumentException(\sprintf('Invalid UID cursor value for field "%s".', $field), 0, $exception);
}

return [$value, $type];
}

if (!\in_array($type, [Types::INTEGER, Types::BIGINT, Types::SMALLINT, Types::STRING, Types::GUID, Types::FLOAT, Types::DECIMAL, Types::BOOLEAN, Types::DATETIME_MUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATETIMETZ_IMMUTABLE], true)) {
throw new InvalidArgumentException(\sprintf('Doctrine type "%s" of cursor field "%s" is not supported.', $type, $field));
}
Expand Down Expand Up @@ -342,6 +358,10 @@ private function normalizeCursorContextValue(QueryBuilder $queryBuilder, mixed $
if ($value instanceof \UnitEnum) {
return ['class' => $value::class, 'name' => $value->name];
}
if ($value instanceof AbstractUid) {
return ['class' => $value::class, 'value' => $value->toString()];
}

if (\is_object($value)) {
$metadataFactory = $queryBuilder->getEntityManager()->getMetadataFactory();
if (!$metadataFactory->isTransient($value::class)) {
Expand Down
15 changes: 14 additions & 1 deletion src/Pagination/tests/Adapter/CursorValuesTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
use Symfony\UX\Pagination\Adapter\CursorValuesTrait;

#[CoversTrait(CursorValuesTrait::class)]
Expand Down Expand Up @@ -105,6 +107,17 @@ public function getCreatedAt(): \DateTimeImmutable
self::assertSame(['2023-01-02T03:04:05.000000Z'], $this->encoder->extractCursorValues($item, ['createdAt']));
}

public function testExtractNormalizesUids(): void
{
$uuid = Uuid::fromString('0192e4a5-7b6c-7d8e-9f01-23456789abcd');
$ulid = Ulid::fromString('01JB2AAYVC7Z4DMQ5W5DSJ1QGC');

self::assertSame(
['0192e4a5-7b6c-7d8e-9f01-23456789abcd', '01JB2AAYVC7Z4DMQ5W5DSJ1QGC'],
$this->encoder->extractCursorValues(['id' => $uuid, 'reference' => $ulid], ['id', 'reference']),
);
}

public function testExtractRejectsNull(): void
{
$this->expectException(\RuntimeException::class);
Expand All @@ -129,7 +142,7 @@ public function testExtractThrowsForMissingField(): void
public function testExtractThrowsForUnsupportedValueType(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('must be scalar or \DateTimeInterface');
$this->expectExceptionMessage('must be scalar, \DateTimeInterface or a Symfony UID');

$this->encoder->extractCursorValues(['field' => new \stdClass()], ['field']);
}
Expand Down
111 changes: 110 additions & 1 deletion src/Pagination/tests/Adapter/DoctrineOrmAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
use Symfony\UX\Pagination\Adapter\DoctrineOrmAdapter;
use Symfony\UX\Pagination\Cursor\CursorBoundary;
use Symfony\UX\Pagination\Cursor\CursorOrder;
Expand All @@ -26,6 +28,7 @@
use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author;
use Symfony\UX\Pagination\Tests\Fixtures\Entity\Book;
use Symfony\UX\Pagination\Tests\Fixtures\Entity\Category;
use Symfony\UX\Pagination\Tests\Fixtures\Entity\Ticket;
use Symfony\UX\Pagination\Tests\Fixtures\EntityManagerFactory;

#[CoversClass(DoctrineOrmAdapter::class)]
Expand All @@ -44,7 +47,7 @@ protected function setUp(): void
try {
$this->queryCollector = new QueryCollector();
$this->entityManager = EntityManagerFactory::create(
[Author::class, Book::class, Category::class],
[Author::class, Book::class, Category::class, Ticket::class],
fn ($config) => $config->setMiddlewares([new Middleware($this->queryCollector)]),
);

Expand Down Expand Up @@ -1228,6 +1231,112 @@ public function testCursorRejectsInvalidDateValues(): void
$this->adapter->sliceWithCursor($queryBuilder, new CursorBoundary(['not-a-date', 1]), 10, $order);
}

public function testCursorPaginationWithUuidIdentifier(): void
{
$ids = $this->persistTickets(7);
sort($ids);

$qb = $this->entityManager->createQueryBuilder()
->select('t')
->from(Ticket::class, 't');
$order = $this->adapter->resolveCursorOrder($qb, ['id'], 'ASC');

$page1 = $this->adapter->sliceWithCursor($qb, null, 3, $order);
$page2 = $this->adapter->sliceWithCursor($qb, $page1->next, 3, $order);
$page3 = $this->adapter->sliceWithCursor($qb, $page2->next, 3, $order);

self::assertSame(\array_slice($ids, 0, 3), self::ticketIds($page1->items));
self::assertSame(\array_slice($ids, 3, 3), self::ticketIds($page2->items));
self::assertSame(\array_slice($ids, 6), self::ticketIds($page3->items));
self::assertFalse($page3->hasNext);
self::assertIsString($page1->next?->values[0]);

$back = $this->adapter->sliceWithCursor($qb, $page2->previous, 3, $order);
self::assertSame(self::ticketIds($page1->items), self::ticketIds($back->items));
}

public function testCursorPaginationWithUlidField(): void
{
$this->persistTickets(5);

$qb = $this->entityManager->createQueryBuilder()
->select('t')
->from(Ticket::class, 't');
$order = $this->adapter->resolveCursorOrder($qb, ['reference'], 'DESC');

$page1 = $this->adapter->sliceWithCursor($qb, null, 2, $order);
$page2 = $this->adapter->sliceWithCursor($qb, $page1->next, 2, $order);
$page3 = $this->adapter->sliceWithCursor($qb, $page2->next, 2, $order);

$references = array_map(
static fn (Ticket $ticket): string => $ticket->getReference()->toString(),
[...$page1->items, ...$page2->items, ...$page3->items],
);
$expected = $references;
rsort($expected);

self::assertCount(5, array_unique($references));
self::assertSame($expected, $references);
self::assertFalse($page3->hasNext);
}

public function testCursorRejectsInvalidUidValues(): void
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('t')
->from(Ticket::class, 't');
$order = $this->adapter->resolveCursorOrder($queryBuilder, ['id'], 'ASC');

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid UID cursor value for field "id".');

$this->adapter->sliceWithCursor($queryBuilder, new CursorBoundary(['not-a-uuid']), 10, $order);
}

public function testCursorContextNormalizesUidParameters(): void
{
$uuid = Uuid::fromString('0192e4a5-7b6c-7d8e-9f01-23456789abcd');
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('t')
->from(Ticket::class, 't')
->where('t.id <> :id')
->setParameter('id', $uuid, 'uuid');

$context = json_decode($this->adapter->getCursorContext($queryBuilder, null), true, flags: \JSON_THROW_ON_ERROR);

self::assertSame(
['class' => $uuid::class, 'value' => '0192e4a5-7b6c-7d8e-9f01-23456789abcd'],
$context['parameters']['id']['value'],
);
}

/**
* @return list<string> the persisted ticket ids, in RFC 4122 form
*/
private function persistTickets(int $count): array
{
$ids = [];
for ($i = 0; $i < $count; ++$i) {
$ticket = new Ticket(Uuid::v7(), new Ulid());
$this->entityManager->persist($ticket);
$ids[] = $ticket->getId()->toString();
}
$this->entityManager->flush();
$this->entityManager->clear();

return $ids;
}

/**
* @param list<mixed> $tickets
*
* @return list<string>
*/
private static function ticketIds(array $tickets): array
{
return array_map(static fn (Ticket $ticket): string => $ticket->getId()->toString(), $tickets);
}

/**
* Test error handling: cursor values count mismatch.
*/
Expand Down
44 changes: 44 additions & 0 deletions src/Pagination/tests/Fixtures/Entity/Ticket.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\Pagination\Tests\Fixtures\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'tickets')]
class Ticket
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;

#[ORM\Column(type: 'ulid', unique: true)]
private Ulid $reference;

public function __construct(Uuid $id, Ulid $reference)
{
$this->id = $id;
$this->reference = $reference;
}

public function getId(): Uuid
{
return $this->id;
}

public function getReference(): Ulid
{
return $this->reference;
}
}
9 changes: 9 additions & 0 deletions src/Pagination/tests/Fixtures/EntityManagerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
namespace Symfony\UX\Pagination\Tests\Fixtures;

use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Configuration;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\UX\Pagination\Tests\Fixtures\Entity\Author;

/**
Expand All @@ -29,6 +32,12 @@ final class EntityManagerFactory
*/
public static function create(array $entities = [Author::class], ?callable $configure = null): EntityManager
{
foreach ([UuidType::NAME => UuidType::class, UlidType::NAME => UlidType::class] as $name => $class) {
if (!Type::hasType($name)) {
Type::addType($name, $class);
}
}

$config = method_exists(ORMSetup::class, 'createAttributeMetadataConfig')
? ORMSetup::createAttributeMetadataConfig(
paths: [__DIR__.'/Entity'],
Expand Down
Loading