1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229
|
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\Common\Collections\Criteria;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\StringType;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Tests\OrmFunctionalTestCase;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\Group;
use Psr\Cache\CacheItemInterface;
use Stringable;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\CacheItem;
use function array_map;
use function is_string;
use function iterator_to_array;
/**
* When using a {@see \Doctrine\ORM\Tools\Pagination\Paginator} to iterate over a query
* that has entities with a custom DBAL type used in the identifier, then `$id->__toString()`
* is used implicitly by {@see \PDOStatement::bindValue()}, instead of being converted by the
* expected {@see \Doctrine\DBAL\Types\Type::convertToDatabaseValue()}.
*
* In order to reproduce this, you must have identifiers implementing
* `#__toString()` (to allow {@see \Doctrine\ORM\UnitOfWork} to hash them) and other accessors
* that are used by the custom DBAL type during DB/PHP conversions.
*
* If `#__toString()` and the DBAL type conversions are asymmetric, then the paginator will fail
* to find records.
* Tricky situation, but this very much affects `ramsey/uuid-doctrine` and anyone relying on (for
* example) the {@see \Ramsey\Uuid\Doctrine\UuidBinaryType} type.
*/
#[Group('GH7820')]
class GH7820Test extends OrmFunctionalTestCase
{
private const SONG = [
'What is this song all about?',
'Can\'t figure any lyrics out',
'How do the words to it go?',
'I wish you\'d tell me, I don\'t know',
'Don\'t know, don\'t know, don\'t know, I don\'t know!',
'Don\'t know, don\'t know, don\'t know...',
];
protected function setUp(): void
{
parent::setUp();
if (! Type::hasType(GH7820LineTextType::class)) {
Type::addType(GH7820LineTextType::class, GH7820LineTextType::class);
}
$this->setUpEntitySchema([GH7820Line::class]);
$this->_em->createQuery('DELETE FROM ' . GH7820Line::class . ' l')
->execute();
foreach (self::SONG as $index => $line) {
$this->_em->persist(new GH7820Line(GH7820LineText::fromText($line), $index));
}
$this->_em->flush();
}
public function testWillFindSongsInPaginator(): void
{
$lines = $this->fetchSongLinesWithPaginator();
self::assertSame(self::SONG, $lines);
}
#[Group('GH7837')]
public function testWillFindSongsInPaginatorEvenWithCachedQueryParsing(): void
{
// Enable the query cache
$this->_em->getConfiguration()
->getQueryCache()
->clear();
// Fetch song lines with the paginator, also priming the query cache
$lines = $this->fetchSongLinesWithPaginator();
self::assertSame(self::SONG, $lines, 'Expected to return expected data before query cache is populated with DQL -> SQL translation. Were SQL parameters translated?');
// Fetch song lines again
$lines = $this->fetchSongLinesWithPaginator();
self::assertSame(self::SONG, $lines, 'Expected to return expected data even when DQL -> SQL translation is present in cache. Were SQL parameters translated again?');
}
public function testPaginatorDoesNotForceCacheToUpdateEntries(): void
{
$this->_em->getConfiguration()->setQueryCache(new class extends ArrayAdapter {
public function save(CacheItemInterface $item): bool
{
Assert::assertFalse($this->hasItem($item->getKey()), 'The cache should not have to overwrite the entry');
return parent::save($item);
}
});
// "Prime" the cache (in fact, that should not even happen)
$this->fetchSongLinesWithPaginator();
// Make sure we can query again without overwriting the cache
$this->fetchSongLinesWithPaginator();
}
public function testPaginatorQueriesWillBeCached(): void
{
$cache = new class extends ArrayAdapter {
/** @var bool */
private $failOnCacheMiss = false;
public function failOnCacheMiss(): void
{
$this->failOnCacheMiss = true;
}
public function getItem($key): CacheItem
{
$item = parent::getItem($key);
Assert::assertTrue(! $this->failOnCacheMiss || $item->isHit(), 'cache was missed');
return $item;
}
};
$this->_em->getConfiguration()->setQueryCache($cache);
// Prime the cache
$this->fetchSongLinesWithPaginator();
$cache->failOnCacheMiss();
$this->fetchSongLinesWithPaginator();
}
private function fetchSongLinesWithPaginator(): array
{
$query = $this->_em->getRepository(GH7820Line::class)
->createQueryBuilder('l')
->orderBy('l.lineNumber', Criteria::ASC)
->setMaxResults(100);
return array_map(static fn (GH7820Line $line): string => $line->toString(), iterator_to_array(new Paginator($query)));
}
}
#[Entity]
class GH7820Line
{
public function __construct(
#[Id]
#[Column(type: 'Doctrine\Tests\ORM\Functional\Ticket\GH7820LineTextType', length: 255)]
private GH7820LineText $text,
#[Column(type: 'integer')]
private int $lineNumber,
) {
}
public function toString(): string
{
return $this->text->getText();
}
}
final class GH7820LineText implements Stringable
{
private function __construct(private string $text)
{
}
public static function fromText(string $text): self
{
return new self($text);
}
public function getText(): string
{
return $this->text;
}
public function __toString(): string
{
return 'Line: ' . $this->text;
}
}
final class GH7820LineTextType extends StringType
{
/**
* {@inheritDoc}
*/
public function convertToPHPValue($value, AbstractPlatform $platform): mixed
{
$text = parent::convertToPHPValue($value, $platform);
if (! is_string($text)) {
return $text;
}
return GH7820LineText::fromText($text);
}
/**
* {@inheritDoc}
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): mixed
{
if (! $value instanceof GH7820LineText) {
return parent::convertToDatabaseValue($value, $platform);
}
return parent::convertToDatabaseValue($value->getText(), $platform);
}
/** {@inheritDoc} */
public function getName(): string
{
return self::class;
}
}
|