Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
fail-fast: false
matrix:
php-version: [ '8.1', '8.2', '8.3', '8.4' ]
doctrine-version: [ '^2.19', '^3.2' ]
doctrine-version: [ '^2.19', '^3' ]
dependency-version: [ prefer-lowest, prefer-stable ]
steps:
-
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
],
"require": {
"php": "^8.1",
"doctrine/dbal": "^3.7 || ^4.0",
"doctrine/orm": "^2.19.7 || ^3.2"
},
"require-dev": {
"composer/semver": "^3.0",
"doctrine/collections": "^2.2",
"doctrine/dbal": "^3.9 || ^4.0",
"doctrine/persistence": "^3.3",
"editorconfig-checker/editorconfig-checker": "^10.6.0",
"ergebnis/composer-normalize": "^2.42.0",
Expand Down Expand Up @@ -60,7 +61,7 @@
"check:dependencies": "composer-dependency-analyser",
"check:ec": "ec src tests",
"check:tests": "phpunit tests",
"check:types": "phpstan analyse -vvv",
"check:types": "phpstan analyse -vv --ansi",
"fix:cs": "phpcbf"
}
}
11 changes: 7 additions & 4 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ parameters:
paths:
- src
- tests
excludePaths:
analyse:
- tests/Fixtures/Compat
checkMissingCallableSignature: true
checkUninitializedProperties: true
checkTooWideReturnTypesInProtectedAndPublicMethods: true
Expand All @@ -29,15 +32,15 @@ parameters:
identifier: 'identical.alwaysFalse'
reportUnmatched: false
path: 'src/EntityPreloader.php'
-
identifier: shipmonk.defaultMatchArmWithEnum
reportUnmatched: false # only new dbal issue
path: 'src/EntityPreloader.php'
-
message: '#Result of \|\| is always false#'
identifier: 'booleanOr.alwaysFalse'
reportUnmatched: false
path: 'src/EntityPreloader.php'
-
message: '#has an uninitialized property \$id#'
identifier: 'property.uninitialized'
path: 'tests/Fixtures/Blog'
-
identifier: 'property.onlyWritten'
path: 'tests/Fixtures/Synthetic'
Expand Down
74 changes: 70 additions & 4 deletions src/EntityPreloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace ShipMonk\DoctrineEntityPreloader;

use ArrayAccess;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
Expand Down Expand Up @@ -141,7 +144,7 @@ private function loadProxies(
}

foreach (array_chunk($uninitializedIds, $batchSize) as $idsChunk) {
$this->loadEntitiesBy($classMetadata, $identifierName, $idsChunk, $maxFetchJoinSameFieldCount);
$this->loadEntitiesBy($classMetadata, $identifierName, $classMetadata, $idsChunk, $maxFetchJoinSameFieldCount);
}

return array_values($uniqueEntities);
Expand Down Expand Up @@ -270,6 +273,7 @@ private function preloadOneToManyInner(
$targetEntitiesList = $this->loadEntitiesBy(
$targetClassMetadata,
$targetPropertyName,
$sourceClassMetadata,
$uninitializedSourceEntityIdsChunk,
$maxFetchJoinSameFieldCount,
$associationMapping['orderBy'] ?? [],
Expand Down Expand Up @@ -318,12 +322,18 @@ private function preloadManyToManyInner(
$sourceIdentifierName = $sourceClassMetadata->getSingleIdentifierFieldName();
$targetIdentifierName = $targetClassMetadata->getSingleIdentifierFieldName();

$sourceIdentifierType = $this->getIdentifierFieldType($sourceClassMetadata);

$manyToManyRows = $this->entityManager->createQueryBuilder()
->select("source.{$sourceIdentifierName} AS sourceId", "target.{$targetIdentifierName} AS targetId")
->from($sourceClassMetadata->getName(), 'source')
->join("source.{$sourcePropertyName}", 'target')
->andWhere('source IN (:sourceEntityIds)')
->setParameter('sourceEntityIds', $uninitializedSourceEntityIdsChunk)
->setParameter(
'sourceEntityIds',
$this->convertFieldValuesToDatabaseValues($sourceIdentifierType, $uninitializedSourceEntityIdsChunk),
$this->deduceArrayParameterType($sourceIdentifierType),
)
->getQuery()
->getResult();

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

foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) {
foreach ($this->loadEntitiesBy($targetClassMetadata, $targetIdentifierName, $sourceClassMetadata, array_values($uninitializedTargetEntityIds), $maxFetchJoinSameFieldCount) as $targetEntity) {
$targetEntityKey = (string) $targetIdentifierReflection->getValue($targetEntity);
$targetEntities[$targetEntityKey] = $targetEntity;
}
Expand Down Expand Up @@ -404,15 +414,18 @@ private function preloadToOne(
/**
* @param ClassMetadata<T> $targetClassMetadata
* @param list<mixed> $fieldValues
* @param ClassMetadata<R> $referencedClassMetadata
* @param non-negative-int $maxFetchJoinSameFieldCount
* @param array<string, 'asc'|'desc'> $orderBy
* @return list<T>
*
* @template T of E
* @template R of E
*/
private function loadEntitiesBy(
ClassMetadata $targetClassMetadata,
string $fieldName,
ClassMetadata $referencedClassMetadata,
array $fieldValues,
int $maxFetchJoinSameFieldCount,
array $orderBy = [],
Expand All @@ -422,13 +435,18 @@ private function loadEntitiesBy(
return [];
}

$referencedType = $this->getIdentifierFieldType($referencedClassMetadata);
$rootLevelAlias = 'e';

$queryBuilder = $this->entityManager->createQueryBuilder()
->select($rootLevelAlias)
->from($targetClassMetadata->getName(), $rootLevelAlias)
->andWhere("{$rootLevelAlias}.{$fieldName} IN (:fieldValues)")
->setParameter('fieldValues', $fieldValues);
->setParameter(
'fieldValues',
$this->convertFieldValuesToDatabaseValues($referencedType, $fieldValues),
$this->deduceArrayParameterType($referencedType),
);

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

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

private function deduceArrayParameterType(Type $dbalType): ArrayParameterType|int|null // @phpstan-ignore return.unusedType (old dbal compat)
{
return match ($dbalType->getBindingType()) {
ParameterType::INTEGER => ArrayParameterType::INTEGER,
ParameterType::STRING => ArrayParameterType::STRING,
ParameterType::ASCII => ArrayParameterType::ASCII,
ParameterType::BINARY => ArrayParameterType::BINARY,
default => null,
};
}

/**
* @param array<mixed> $fieldValues
* @return list<mixed>
*/
private function convertFieldValuesToDatabaseValues(
Type $dbalType,
array $fieldValues,
): array
{
$connection = $this->entityManager->getConnection();
$platform = $connection->getDatabasePlatform();

$convertedValues = [];
foreach ($fieldValues as $value) {
$convertedValues[] = $dbalType->convertToDatabaseValue($value, $platform);
}

return $convertedValues;
}

/**
* @param ClassMetadata<C> $classMetadata
*
* @template C of E
*/
private function getIdentifierFieldType(ClassMetadata $classMetadata): Type
{
$identifierName = $classMetadata->getSingleIdentifierFieldName();
$sourceIdTypeName = $classMetadata->getTypeOfField($identifierName);

if ($sourceIdTypeName === null) {
throw new LogicException("Identifier field '{$identifierName}' for class '{$classMetadata->getName()}' has unknown field type.");
}

return Type::getType($sourceIdTypeName);
}

/**
* @param ClassMetadata<S> $sourceClassMetadata
* @param array<string, array<string, int>> $alreadyPreloadedJoins
Expand Down
22 changes: 14 additions & 8 deletions tests/EntityPreloadBlogManyHasManyInversedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@

namespace ShipMonkTests\DoctrineEntityPreloader;

use Doctrine\DBAL\Types\Type as DbalType;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHPUnit\Framework\Attributes\DataProvider;
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Tag;
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;

class EntityPreloadBlogManyHasManyInversedTest extends TestCase
{

public function testManyHasManyInversedUnoptimized(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyInversedUnoptimized(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

Expand All @@ -23,9 +26,10 @@ public function testManyHasManyInversedUnoptimized(): void
]);
}

public function testManyHasManyInversedWithFetchJoin(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyInversedWithFetchJoin(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

public function testManyHasManyInversedWithEagerFetchMode(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyInversedWithEagerFetchMode(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

public function testManyHasManyInversedWithPreload(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyInversedWithPreload(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

$tags = $this->getEntityManager()->getRepository(Tag::class)->findAll();
$this->getEntityPreloader()->preload($tags, 'articles');
Expand Down
35 changes: 24 additions & 11 deletions tests/EntityPreloadBlogManyHasManyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

namespace ShipMonkTests\DoctrineEntityPreloader;

use Doctrine\DBAL\Types\Type as DbalType;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHPUnit\Framework\Attributes\DataProvider;
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Article;
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
use function array_map;

class EntityPreloadBlogManyHasManyTest extends TestCase
{

public function testManyHasManyUnoptimized(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyUnoptimized(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

Expand All @@ -23,19 +27,25 @@ public function testManyHasManyUnoptimized(): void
]);
}

public function testOneHasManyWithWithManualPreloadUsingPartial(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testOneHasManyWithWithManualPreloadUsingPartial(DbalType $primaryKey): void
{
$this->skipIfPartialEntitiesAreNotSupported();
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
$platform = $this->getEntityManager()->getConnection()->getDatabasePlatform();
$rawArticleIds = array_map(
static fn (Article $article) => $primaryKey->convertToDatabaseValue($article->getId(), $platform),
$articles,
);

$this->getEntityManager()->createQueryBuilder()
->select('PARTIAL article.{id}', 'tag')
->from(Article::class, 'article')
->leftJoin('article.tags', 'tag')
->where('article IN (:articles)')
->setParameter('articles', $articles)
->setParameter('articles', $rawArticleIds, $this->deduceArrayParameterType($primaryKey))
->getQuery()
->getResult();

Expand All @@ -47,9 +57,10 @@ public function testOneHasManyWithWithManualPreloadUsingPartial(): void
]);
}

public function testManyHasManyWithFetchJoin(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyWithFetchJoin(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

public function testManyHasManyWithEagerFetchMode(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyWithEagerFetchMode(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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

public function testManyHasManyWithPreload(): void
#[DataProvider('providePrimaryKeyTypes')]
public function testManyHasManyWithPreload(DbalType $primaryKey): void
{
$this->createDummyBlogData(articleInEachCategoryCount: 5, tagForEachArticleCount: 5);
$this->createDummyBlogData($primaryKey, articleInEachCategoryCount: 5, tagForEachArticleCount: 5);

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