Skip to content

Commit 3dfc855

Browse files
janedbalJanTvrdik
andauthored
Support UUIDs in PK stored in binary form (#20)
* Support UUIDs in PK stored in binary form * Add BinaryIdType::getName for old dbal * Compat with old dbal * Fix cs in compat file * Lowest dbal at 3.7 * Bump ORM to avoid Unhandled match case of type Doctrine\DBAL\ParameterType * Less confusion in CI tests with ORM 3 * Keep lowest orm at 3.2 as 3.5 is very painful to use * remove convertFieldValuesToDatabaseValues() * Test custom PKs stored as binary/string/integer * Revert "remove convertFieldValuesToDatabaseValues()" This reverts commit a132276. * Use DBAL static methods instead of type registry * Simplify PrimaryKeyTypes * Fix tests for old doctrine * Skip tests broken by ORM bug and add one working testcase for each * Move skipX methods closer together * DbalType::addType: Fix broken edgecase combination of dbal+orm versions --------- Co-authored-by: Jan Tvrdík <git@jantvrdik.com>
1 parent 03ca7e1 commit 3dfc855

26 files changed

+741
-181
lines changed

.github/workflows/checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
fail-fast: false
3434
matrix:
3535
php-version: [ '8.1', '8.2', '8.3', '8.4' ]
36-
doctrine-version: [ '^2.19', '^3.2' ]
36+
doctrine-version: [ '^2.19', '^3' ]
3737
dependency-version: [ prefer-lowest, prefer-stable ]
3838
steps:
3939
-

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
],
77
"require": {
88
"php": "^8.1",
9+
"doctrine/dbal": "^3.7 || ^4.0",
910
"doctrine/orm": "^2.19.7 || ^3.2"
1011
},
1112
"require-dev": {
13+
"composer/semver": "^3.0",
1214
"doctrine/collections": "^2.2",
13-
"doctrine/dbal": "^3.9 || ^4.0",
1415
"doctrine/persistence": "^3.3",
1516
"editorconfig-checker/editorconfig-checker": "^10.6.0",
1617
"ergebnis/composer-normalize": "^2.42.0",
@@ -60,7 +61,7 @@
6061
"check:dependencies": "composer-dependency-analyser",
6162
"check:ec": "ec src tests",
6263
"check:tests": "phpunit tests",
63-
"check:types": "phpstan analyse -vvv",
64+
"check:types": "phpstan analyse -vv --ansi",
6465
"fix:cs": "phpcbf"
6566
}
6667
}

phpstan.neon.dist

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ parameters:
1010
paths:
1111
- src
1212
- tests
13+
excludePaths:
14+
analyse:
15+
- tests/Fixtures/Compat
1316
checkMissingCallableSignature: true
1417
checkUninitializedProperties: true
1518
checkTooWideReturnTypesInProtectedAndPublicMethods: true
@@ -29,15 +32,15 @@ parameters:
2932
identifier: 'identical.alwaysFalse'
3033
reportUnmatched: false
3134
path: 'src/EntityPreloader.php'
35+
-
36+
identifier: shipmonk.defaultMatchArmWithEnum
37+
reportUnmatched: false # only new dbal issue
38+
path: 'src/EntityPreloader.php'
3239
-
3340
message: '#Result of \|\| is always false#'
3441
identifier: 'booleanOr.alwaysFalse'
3542
reportUnmatched: false
3643
path: 'src/EntityPreloader.php'
37-
-
38-
message: '#has an uninitialized property \$id#'
39-
identifier: 'property.uninitialized'
40-
path: 'tests/Fixtures/Blog'
4144
-
4245
identifier: 'property.onlyWritten'
4346
path: 'tests/Fixtures/Synthetic'

src/EntityPreloader.php

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
namespace ShipMonk\DoctrineEntityPreloader;
44

55
use ArrayAccess;
6+
use Doctrine\DBAL\ArrayParameterType;
7+
use Doctrine\DBAL\ParameterType;
8+
use Doctrine\DBAL\Types\Type;
69
use Doctrine\ORM\EntityManagerInterface;
710
use Doctrine\ORM\Mapping\ClassMetadata;
811
use Doctrine\ORM\PersistentCollection;
@@ -141,7 +144,7 @@ private function loadProxies(
141144
}
142145

143146
foreach (array_chunk($uninitializedIds, $batchSize) as $idsChunk) {
144-
$this->loadEntitiesBy($classMetadata, $identifierName, $idsChunk, $maxFetchJoinSameFieldCount);
147+
$this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount);
145148
}
146149

147150
return array_values($uniqueEntities);
@@ -270,6 +273,7 @@ private function preloadOneToManyInner(
270273
$targetEntitiesList = $this->loadEntitiesBy(
271274
$targetClassMetadata,
272275
$targetPropertyName,
276+
$sourceClassMetadata,
273277
$uninitializedSourceEntityIdsChunk,
274278
$maxFetchJoinSameFieldCount,
275279
$associationMapping['orderBy'] ?? [],
@@ -318,12 +322,18 @@ private function preloadManyToManyInner(
318322
$sourceIdentifierName = $sourceClassMetadata->getSingleIdentifierFieldName();
319323
$targetIdentifierName = $targetClassMetadata->getSingleIdentifierFieldName();
320324

325+
$sourceIdentifierType = $this->getIdentifierFieldType($sourceClassMetadata);
326+
321327
$manyToManyRows = $this->entityManager->createQueryBuilder()
322328
->select("source.{$sourceIdentifierName} AS sourceId", "target.{$targetIdentifierName} AS targetId")
323329
->from($sourceClassMetadata->getName(), 'source')
324330
->join("source.{$sourcePropertyName}", 'target')
325331
->andWhere('source IN (:sourceEntityIds)')
326-
->setParameter('sourceEntityIds', $uninitializedSourceEntityIdsChunk)
332+
->setParameter(
333+
'sourceEntityIds',
334+
$this->convertFieldValuesToDatabaseValues($sourceIdentifierType, $uninitializedSourceEntityIdsChunk),
335+
$this->deduceArrayParameterType($sourceIdentifierType),
336+
)
327337
->getQuery()
328338
->getResult();
329339

@@ -345,7 +355,7 @@ private function preloadManyToManyInner(
345355
$uninitializedTargetEntityIds[$targetEntityKey] = $targetEntityId;
346356
}
347357

348-
foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) {
358+
foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) {
349359
$targetEntityKey = (string) $targetIdentifierReflection->getValue($targetEntity);
350360
$targetEntities[$targetEntityKey] = $targetEntity;
351361
}
@@ -404,15 +414,18 @@ private function preloadToOne(
404414
/**
405415
* @param ClassMetadata<T> $targetClassMetadata
406416
* @param list<mixed> $fieldValues
417+
* @param ClassMetadata<R> $referencedClassMetadata
407418
* @param non-negative-int $maxFetchJoinSameFieldCount
408419
* @param array<string, 'asc'|'desc'> $orderBy
409420
* @return list<T>
410421
*
411422
* @template T of E
423+
* @template R of E
412424
*/
413425
private function loadEntitiesBy(
414426
ClassMetadata $targetClassMetadata,
415427
string $fieldName,
428+
ClassMetadata $referencedClassMetadata,
416429
array $fieldValues,
417430
int $maxFetchJoinSameFieldCount,
418431
array $orderBy = [],
@@ -422,13 +435,18 @@ private function loadEntitiesBy(
422435
return [];
423436
}
424437

438+
$referencedType = $this->getIdentifierFieldType($referencedClassMetadata);
425439
$rootLevelAlias = 'e';
426440

427441
$queryBuilder = $this->entityManager->createQueryBuilder()
428442
->select($rootLevelAlias)
429443
->from($targetClassMetadata->getName(), $rootLevelAlias)
430444
->andWhere("{$rootLevelAlias}.{$fieldName} IN (:fieldValues)")
431-
->setParameter('fieldValues', $fieldValues);
445+
->setParameter(
446+
'fieldValues',
447+
$this->convertFieldValuesToDatabaseValues($referencedType, $fieldValues),
448+
$this->deduceArrayParameterType($referencedType),
449+
);
432450

433451
$this->addFetchJoinsToPreventFetchDuringHydration($rootLevelAlias, $queryBuilder, $targetClassMetadata, $maxFetchJoinSameFieldCount);
434452

@@ -439,6 +457,54 @@ private function loadEntitiesBy(
439457
return $queryBuilder->getQuery()->getResult();
440458
}
441459

460+
private function deduceArrayParameterType(Type $dbalType): ArrayParameterType|int|null // @phpstan-ignore return.unusedType (old dbal compat)
461+
{
462+
return match ($dbalType->getBindingType()) {
463+
ParameterType::INTEGER => ArrayParameterType::INTEGER,
464+
ParameterType::STRING => ArrayParameterType::STRING,
465+
ParameterType::ASCII => ArrayParameterType::ASCII,
466+
ParameterType::BINARY => ArrayParameterType::BINARY,
467+
default => null,
468+
};
469+
}
470+
471+
/**
472+
* @param array<mixed> $fieldValues
473+
* @return list<mixed>
474+
*/
475+
private function convertFieldValuesToDatabaseValues(
476+
Type $dbalType,
477+
array $fieldValues,
478+
): array
479+
{
480+
$connection = $this->entityManager->getConnection();
481+
$platform = $connection->getDatabasePlatform();
482+
483+
$convertedValues = [];
484+
foreach ($fieldValues as $value) {
485+
$convertedValues[] = $dbalType->convertToDatabaseValue($value, $platform);
486+
}
487+
488+
return $convertedValues;
489+
}
490+
491+
/**
492+
* @param ClassMetadata<C> $classMetadata
493+
*
494+
* @template C of E
495+
*/
496+
private function getIdentifierFieldType(ClassMetadata $classMetadata): Type
497+
{
498+
$identifierName = $classMetadata->getSingleIdentifierFieldName();
499+
$sourceIdTypeName = $classMetadata->getTypeOfField($identifierName);
500+
501+
if ($sourceIdTypeName === null) {
502+
throw new LogicException("Identifier field '{$identifierName}' for class '{$classMetadata->getName()}' has unknown field type.");
503+
}
504+
505+
return Type::getType($sourceIdTypeName);
506+
}
507+
442508
/**
443509
* @param ClassMetadata<S> $sourceClassMetadata
444510
* @param array<string, array<string, int>> $alreadyPreloadedJoins

tests/EntityPreloadBlogManyHasManyInversedTest.php

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
namespace ShipMonkTests\DoctrineEntityPreloader;
44

5+
use Doctrine\DBAL\Types\Type as DbalType;
56
use Doctrine\ORM\Mapping\ClassMetadata;
7+
use PHPUnit\Framework\Attributes\DataProvider;
68
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Tag;
79
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
810

911
class EntityPreloadBlogManyHasManyInversedTest extends TestCase
1012
{
1113

12-
public function testManyHasManyInversedUnoptimized(): void
14+
#[DataProvider('providePrimaryKeyTypes')]
15+
public function testManyHasManyInversedUnoptimized(DbalType $primaryKey): void
1316
{
14-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
17+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
1518

1619
$tags = $this->getEntityManager()->getRepository(Tag::class)->findAll();
1720

@@ -23,9 +26,10 @@ public function testManyHasManyInversedUnoptimized(): void
2326
]);
2427
}
2528

26-
public function testManyHasManyInversedWithFetchJoin(): void
29+
#[DataProvider('providePrimaryKeyTypes')]
30+
public function testManyHasManyInversedWithFetchJoin(DbalType $primaryKey): void
2731
{
28-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
32+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
2933

3034
$tags = $this->getEntityManager()->createQueryBuilder()
3135
->select('tag', 'article')
@@ -41,9 +45,10 @@ public function testManyHasManyInversedWithFetchJoin(): void
4145
]);
4246
}
4347

44-
public function testManyHasManyInversedWithEagerFetchMode(): void
48+
#[DataProvider('providePrimaryKeyTypes')]
49+
public function testManyHasManyInversedWithEagerFetchMode(DbalType $primaryKey): void
4550
{
46-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
51+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
4752

4853
// for eagerly loaded Many-To-Many associations one query has to be made for each collection
4954
// https://www.doctrine-project.org/projects/doctrine-orm/en/3.2/reference/working-with-objects.html#by-eager-loading
@@ -62,9 +67,10 @@ public function testManyHasManyInversedWithEagerFetchMode(): void
6267
]);
6368
}
6469

65-
public function testManyHasManyInversedWithPreload(): void
70+
#[DataProvider('providePrimaryKeyTypes')]
71+
public function testManyHasManyInversedWithPreload(DbalType $primaryKey): void
6672
{
67-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
73+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
6874

6975
$tags = $this->getEntityManager()->getRepository(Tag::class)->findAll();
7076
$this->getEntityPreloader()->preload($tags, 'articles');

tests/EntityPreloadBlogManyHasManyTest.php

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
namespace ShipMonkTests\DoctrineEntityPreloader;
44

5+
use Doctrine\DBAL\Types\Type as DbalType;
56
use Doctrine\ORM\Mapping\ClassMetadata;
7+
use PHPUnit\Framework\Attributes\DataProvider;
68
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Article;
79
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
10+
use function array_map;
811

912
class EntityPreloadBlogManyHasManyTest extends TestCase
1013
{
1114

12-
public function testManyHasManyUnoptimized(): void
15+
#[DataProvider('providePrimaryKeyTypes')]
16+
public function testManyHasManyUnoptimized(DbalType $primaryKey): void
1317
{
14-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
18+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
1519

1620
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
1721

@@ -23,19 +27,25 @@ public function testManyHasManyUnoptimized(): void
2327
]);
2428
}
2529

26-
public function testOneHasManyWithWithManualPreloadUsingPartial(): void
30+
#[DataProvider('providePrimaryKeyTypes')]
31+
public function testOneHasManyWithWithManualPreloadUsingPartial(DbalType $primaryKey): void
2732
{
2833
$this->skipIfPartialEntitiesAreNotSupported();
29-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
34+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
3035

3136
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
37+
$platform = $this->getEntityManager()->getConnection()->getDatabasePlatform();
38+
$rawArticleIds = array_map(
39+
static fn (Article $article) => $primaryKey->convertToDatabaseValue($article->getId(), $platform),
40+
$articles,
41+
);
3242

3343
$this->getEntityManager()->createQueryBuilder()
3444
->select('PARTIAL article.{id}', 'tag')
3545
->from(Article::class, 'article')
3646
->leftJoin('article.tags', 'tag')
3747
->where('article IN (:articles)')
38-
->setParameter('articles', $articles)
48+
->setParameter('articles', $rawArticleIds, $this->deduceArrayParameterType($primaryKey))
3949
->getQuery()
4050
->getResult();
4151

@@ -47,9 +57,10 @@ public function testOneHasManyWithWithManualPreloadUsingPartial(): void
4757
]);
4858
}
4959

50-
public function testManyHasManyWithFetchJoin(): void
60+
#[DataProvider('providePrimaryKeyTypes')]
61+
public function testManyHasManyWithFetchJoin(DbalType $primaryKey): void
5162
{
52-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
63+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
5364

5465
$articles = $this->getEntityManager()->createQueryBuilder()
5566
->select('article', 'tag')
@@ -65,9 +76,10 @@ public function testManyHasManyWithFetchJoin(): void
6576
]);
6677
}
6778

68-
public function testManyHasManyWithEagerFetchMode(): void
79+
#[DataProvider('providePrimaryKeyTypes')]
80+
public function testManyHasManyWithEagerFetchMode(DbalType $primaryKey): void
6981
{
70-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
82+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
7183

7284
// for eagerly loaded Many-To-Many associations one query has to be made for each collection
7385
// https://www.doctrine-project.org/projects/doctrine-orm/en/3.2/reference/working-with-objects.html#by-eager-loading
@@ -86,9 +98,10 @@ public function testManyHasManyWithEagerFetchMode(): void
8698
]);
8799
}
88100

89-
public function testManyHasManyWithPreload(): void
101+
#[DataProvider('providePrimaryKeyTypes')]
102+
public function testManyHasManyWithPreload(DbalType $primaryKey): void
90103
{
91-
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
104+
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
92105

93106
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
94107
$this->getEntityPreloader()->preload($articles, 'tags');

0 commit comments

Comments
 (0)