Skip to content

Commit b191caa

Browse files
authored
Add human readable tests comparing multiple n+1 approaches (#2)
1 parent 5ea912c commit b191caa

16 files changed

+1072
-2
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"doctrine/persistence": "^3.1"
1111
},
1212
"require-dev": {
13+
"doctrine/collections": "^2.2",
1314
"doctrine/dbal": "^4",
1415
"editorconfig-checker/editorconfig-checker": "^10.6.0",
1516
"ergebnis/composer-normalize": "^2.42.0",

phpstan.neon.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ parameters:
2424
- ShipMonk\DoctrineEntityPreloader\Exception\RuntimeException
2525

2626
ignoreErrors:
27+
-
28+
message: '#has an uninitialized property \$id#'
29+
identifier: 'property.uninitialized'
30+
path: 'tests/Fixtures/Blog'
2731
-
2832
identifier: 'property.onlyWritten'
2933
path: 'tests/Fixtures/Synthetic'
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\DoctrineEntityPreloader;
4+
5+
use Doctrine\ORM\Mapping\ClassMetadata;
6+
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Article;
7+
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Category;
8+
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
9+
10+
class EntityPreloadBlogManyHasOneDeepTest extends TestCase
11+
{
12+
13+
public function testManyHasOneDeepUnoptimized(): void
14+
{
15+
$this->createDummyBlogData(categoryCount: 5, categoryParentsCount: 5, articleInEachCategoryCount: 5);
16+
17+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
18+
19+
$this->readArticleCategoryParentNames($articles);
20+
21+
self::assertAggregatedQueries([
22+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
23+
['count' => 10, 'query' => 'SELECT * FROM category t0 WHERE t0.id = ?'],
24+
]);
25+
}
26+
27+
public function testManyHasOneDeepWithFetchJoin(): void
28+
{
29+
$this->createDummyBlogData(categoryCount: 5, categoryParentsCount: 5, articleInEachCategoryCount: 5);
30+
31+
$articles = $this->getEntityManager()->createQueryBuilder()
32+
->select('article', 'category', 'parentCategory')
33+
->from(Article::class, 'article')
34+
->leftJoin('article.category', 'category')
35+
->leftJoin('category.parent', 'parentCategory')
36+
->getQuery()
37+
->getResult();
38+
39+
$this->readArticleCategoryParentNames($articles);
40+
41+
self::assertAggregatedQueries([
42+
['count' => 1, 'query' => 'SELECT * FROM article a0_ LEFT JOIN category c1_ ON a0_.category_id = c1_.id LEFT JOIN category c2_ ON c1_.parent_id = c2_.id'],
43+
]);
44+
}
45+
46+
public function testManyHasOneDeepWithEagerFetchMode(): void
47+
{
48+
$this->createDummyBlogData(categoryCount: 5, categoryParentsCount: 5, articleInEachCategoryCount: 5);
49+
50+
$articles = $this->getEntityManager()->createQueryBuilder()
51+
->select('article')
52+
->from(Article::class, 'article')
53+
->getQuery()
54+
->setFetchMode(Article::class, 'category', ClassMetadata::FETCH_EAGER)
55+
->setFetchMode(Category::class, 'parent', ClassMetadata::FETCH_EAGER) // this does not work
56+
->getResult();
57+
58+
$this->readArticleCategoryParentNames($articles);
59+
60+
self::assertAggregatedQueries([
61+
['count' => 1, 'query' => 'SELECT * FROM article a0_'],
62+
['count' => 1, 'query' => 'SELECT * FROM category t0 WHERE t0.id IN (?, ?, ?, ?, ?)'],
63+
['count' => 5, 'query' => 'SELECT * FROM category t0 WHERE t0.id = ?'],
64+
]);
65+
}
66+
67+
public function testManyHasOneDeepWithPreload(): void
68+
{
69+
$this->createDummyBlogData(categoryCount: 5, categoryParentsCount: 5, articleInEachCategoryCount: 5);
70+
71+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
72+
$categories = $this->getEntityPreloader()->preload($articles, 'category');
73+
$this->getEntityPreloader()->preload($categories, 'parent');
74+
75+
$this->readArticleCategoryParentNames($articles);
76+
77+
self::assertAggregatedQueries([
78+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
79+
['count' => 2, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'],
80+
]);
81+
}
82+
83+
/**
84+
* @param array<Article> $articles
85+
*/
86+
private function readArticleCategoryParentNames(array $articles): void
87+
{
88+
foreach ($articles as $article) {
89+
$article->getCategory()?->getParent()?->getName();
90+
}
91+
}
92+
93+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\DoctrineEntityPreloader;
4+
5+
use Doctrine\ORM\Mapping\ClassMetadata;
6+
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Article;
7+
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
8+
9+
class EntityPreloadBlogManyHasOneTest extends TestCase
10+
{
11+
12+
public function testManyHasOneUnoptimized(): void
13+
{
14+
$this->createDummyBlogData(categoryCount: 5, articleInEachCategoryCount: 5);
15+
16+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
17+
18+
$this->readArticleCategoryNames($articles);
19+
20+
self::assertAggregatedQueries([
21+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
22+
['count' => 5, 'query' => 'SELECT * FROM category t0 WHERE t0.id = ?'],
23+
]);
24+
}
25+
26+
public function testManyHasOneWithFetchJoin(): void
27+
{
28+
$this->createDummyBlogData(categoryCount: 5, articleInEachCategoryCount: 5);
29+
30+
$articles = $this->getEntityManager()->createQueryBuilder()
31+
->select('article', 'category')
32+
->from(Article::class, 'article')
33+
->leftJoin('article.category', 'category')
34+
->getQuery()
35+
->getResult();
36+
37+
$this->readArticleCategoryNames($articles);
38+
39+
self::assertAggregatedQueries([
40+
['count' => 1, 'query' => 'SELECT * FROM article a0_ LEFT JOIN category c1_ ON a0_.category_id = c1_.id'],
41+
]);
42+
}
43+
44+
public function testManyHasOneWithEagerFetchMode(): void
45+
{
46+
$this->createDummyBlogData(categoryCount: 5, articleInEachCategoryCount: 5);
47+
48+
$articles = $this->getEntityManager()->createQueryBuilder()
49+
->select('article')
50+
->from(Article::class, 'article')
51+
->getQuery()
52+
->setFetchMode(Article::class, 'category', ClassMetadata::FETCH_EAGER)
53+
->getResult();
54+
55+
$this->readArticleCategoryNames($articles);
56+
57+
self::assertAggregatedQueries([
58+
['count' => 1, 'query' => 'SELECT * FROM article a0_'],
59+
['count' => 1, 'query' => 'SELECT * FROM category t0 WHERE t0.id IN (?, ?, ?, ?, ?)'],
60+
]);
61+
}
62+
63+
public function testManyHasOneWithPreload(): void
64+
{
65+
$this->createDummyBlogData(categoryCount: 5, articleInEachCategoryCount: 5);
66+
67+
$articles = $this->getEntityManager()->getRepository(Article::class)->findAll();
68+
$this->getEntityPreloader()->preload($articles, 'category');
69+
70+
$this->readArticleCategoryNames($articles);
71+
72+
self::assertAggregatedQueries([
73+
['count' => 1, 'query' => 'SELECT * FROM article t0'],
74+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.id IN (?, ?, ?, ?, ?)'],
75+
]);
76+
}
77+
78+
/**
79+
* @param array<Article> $articles
80+
*/
81+
private function readArticleCategoryNames(array $articles): void
82+
{
83+
foreach ($articles as $article) {
84+
$article->getCategory()?->getName();
85+
}
86+
}
87+
88+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\DoctrineEntityPreloader;
4+
5+
use Doctrine\ORM\Mapping\ClassMetadata;
6+
use ShipMonkTests\DoctrineEntityPreloader\Fixtures\Blog\Category;
7+
use ShipMonkTests\DoctrineEntityPreloader\Lib\TestCase;
8+
9+
class EntityPreloadBlogOneHasManyDeepTest extends TestCase
10+
{
11+
12+
public function testOneHasManyDeepUnoptimized(): void
13+
{
14+
$this->createCategoryTree(depth: 5, branchingFactor: 5);
15+
16+
$rootCategories = $this->getEntityManager()->createQueryBuilder()
17+
->select('category')
18+
->from(Category::class, 'category')
19+
->where('category.parent IS NULL')
20+
->getQuery()
21+
->getResult();
22+
23+
$this->readSubSubCategoriesNames($rootCategories);
24+
25+
self::assertAggregatedQueries([
26+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.parent_id IS NULL'],
27+
['count' => 5 + 25, 'query' => 'SELECT * FROM category t0 WHERE t0.parent_id = ?'],
28+
]);
29+
}
30+
31+
public function testOneHasManyDeepWithFetchJoin(): void
32+
{
33+
$this->createCategoryTree(depth: 5, branchingFactor: 5);
34+
35+
$rootCategories = $this->getEntityManager()->createQueryBuilder()
36+
->select('category', 'subCategories', 'subSubCategories')
37+
->from(Category::class, 'category')
38+
->leftJoin('category.children', 'subCategories')
39+
->leftJoin('subCategories.children', 'subSubCategories')
40+
->getQuery()
41+
->getResult();
42+
43+
$this->readSubSubCategoriesNames($rootCategories);
44+
45+
self::assertAggregatedQueries([
46+
['count' => 1, 'query' => 'SELECT * FROM category c0_ LEFT JOIN category c1_ ON c0_.id = c1_.parent_id LEFT JOIN category c2_ ON c1_.id = c2_.parent_id'],
47+
]);
48+
}
49+
50+
public function testOneHasManyDeepWithEagerFetchMode(): void
51+
{
52+
$this->createCategoryTree(depth: 5, branchingFactor: 5);
53+
54+
$rootCategories = $this->getEntityManager()->createQueryBuilder()
55+
->select('category')
56+
->from(Category::class, 'category')
57+
->where('category.parent IS NULL')
58+
->getQuery()
59+
->setFetchMode(Category::class, 'children', ClassMetadata::FETCH_EAGER)
60+
->getResult();
61+
62+
$this->readSubSubCategoriesNames($rootCategories);
63+
64+
self::assertAggregatedQueries([
65+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.parent_id IS NULL'],
66+
['count' => 1, 'query' => 'SELECT * FROM category t0 WHERE t0.parent_id IN (?, ?, ?, ?, ?)'],
67+
['count' => 25, 'query' => 'SELECT * FROM category t0 WHERE t0.parent_id = ?'],
68+
]);
69+
}
70+
71+
public function testOneHasManyDeepWithPreload(): void
72+
{
73+
$this->createCategoryTree(depth: 5, branchingFactor: 5);
74+
75+
$rootCategories = $this->getEntityManager()->createQueryBuilder()
76+
->select('category')
77+
->from(Category::class, 'category')
78+
->where('category.parent IS NULL')
79+
->getQuery()
80+
->getResult();
81+
82+
$subCategories = $this->getEntityPreloader()->preload($rootCategories, 'children');
83+
$this->getEntityPreloader()->preload($subCategories, 'children');
84+
85+
$this->readSubSubCategoriesNames($rootCategories);
86+
87+
self::assertAggregatedQueries([
88+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.parent_id IS NULL'],
89+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.parent_id IN (?, ?, ?, ?, ?)'],
90+
['count' => 1, 'query' => 'SELECT * FROM category c0_ WHERE c0_.parent_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'],
91+
]);
92+
}
93+
94+
private function createCategoryTree(int $depth, int $branchingFactor, ?Category $parent = null): void
95+
{
96+
for ($i = 0; $i < $branchingFactor; $i++) {
97+
$category = new Category("Category $depth-$i", $parent);
98+
$this->getEntityManager()->persist($category);
99+
100+
if ($depth > 1) {
101+
$this->createCategoryTree($depth - 1, $branchingFactor, $category);
102+
}
103+
}
104+
105+
if ($parent === null) {
106+
$this->getEntityManager()->flush();
107+
$this->getEntityManager()->clear();
108+
$this->getQueryLogger()->clear();
109+
}
110+
}
111+
112+
/**
113+
* @param array<Category> $categories
114+
*/
115+
private function readSubSubCategoriesNames(array $categories): void
116+
{
117+
foreach ($categories as $category) {
118+
foreach ($category->getChildren() as $child) {
119+
foreach ($child->getChildren() as $child2) {
120+
$child2->getName();
121+
}
122+
}
123+
}
124+
}
125+
126+
}

0 commit comments

Comments
 (0)