Categories
Doctrine ORM Domain Driven Design Programming Software development Test Driven Development Testing

How to do performant count of child entities in rich entity with doctrine?

TLDR: Use Extra Lazy feature from Doctrine ORM: https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/tutorials/extra-lazy-associations.html

Let’s imagine that we’re working on e-commerce platform. Each user can sold maximum 100 items How to do it properly? How to introduce domain limit on doctrine relationships? How can I introduce it? Let’s use rich entities (trying to follow Domain-Driven-Design and Test-Driven-Development rules):

<?php

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int|null $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    /** @var Collection<int, Item> */
    #[ORM\OneToOne(inversedBy: 'createdBy', targetEntity: Item::class)]
    private Collection $items;

    public function __construct(string $name)
    {
        $this->name = $name;
        $this->items = new ArrayCollection();
    }

    public function addItem(Item $item): void
    {
        $this->items[] = $item;
    }

    public function getItems(): Collection
    {
        return $this->items;
    }
}

and item:

<?php

use Doctrine\ORM\Mapping as ORM;

class Item
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int|null $id = null;

    #[ORM\Column(type: 'string')]
    private string $description;

    #[ORM\Column(type: 'integer')]
    private int $price;

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'items')]
    private User $createdBy;

    public function __construct(string $description, int $price)
    {

    }

    public function setCreator(User $createdBy): void
    {
        $createdBy->addItem($this);
        $this->createdBy = $createdBy;
    }
}

And basic test for that:

<?php

namespace tests;

use Item;
use PHPUnit\Framework\TestCase;
use User;

final class ItemsLimitPerUserTest extends TestCase
{
    public function testItemsLimitPerUser(): void
    {
        $user = new User('John');
        $itemA = new Item('My aswesome item A', 20);
        $itemB = new Item('My aswesome item B', 20);
        $itemC = new Item('My aswesome item C', 20);

        $itemA->setCreator($user);
        $itemB->setCreator($user);
        $itemC->setCreator($user);

        $this->assertEquals(
            3,
            $user->getItems()->count()
        );
    }
}

Now let’s add new test case that adding fourth item will fail (let’s set limit = 3). Basically we will implement items limit per user logic.

public function testItemsLimitPerUserIsMet(): void
{
$user = new User('John');
$itemA = new Item('My aswesome item A', 20);
$itemB = new Item('My aswesome item B', 20);
$itemC = new Item('My aswesome item C', 20);
$itemD = new Item('My aswesome item D', 20);

$itemA->setCreator($user);
$itemB->setCreator($user);
$itemC->setCreator($user);

try {
$itemD->setCreator($user);
} catch (Exception $exception) {
$this->assertInstanceOf(LimitMetException::class, $exception);
}

$this->assertEquals(
3,
$user->getItems()->count()
);
}

Which obviously will fail for that moment:

~/Development/doctrine-playground$ ./vendor/bin/phpunit tests
PHPUnit 11.5.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.27

.F                                                                  2 / 2 (100%)

Time: 00:00.007, Memory: 8.00 MB

There was 1 failure:

1) tests\ItemsLimitPerUserTest::testItemsLimitPerUserIsMet
Failed asserting that 4 matches expected 3.

/home/adt/Development/doctrine-playground/tests/ItemsLimitPerUserTest.php:48

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Now let’s try to implement this requirement (git diff):

diff --git a/src/User.php b/src/User.php
index ec83b61..d8dcde8 100644
--- a/src/User.php
+++ b/src/User.php
@@ -8,6 +8,8 @@ use Doctrine\ORM\Mapping as ORM;
 #[ORM\Table(name: 'users')]
 class User
 {
+    public const ITEMS_LIMIT = 3;
+
     #[ORM\Id]
     #[ORM\GeneratedValue]
     #[ORM\Column(type: 'integer')]
@@ -26,8 +28,15 @@ class User
         $this->items = new ArrayCollection();
     }
 
+    /**
+     * @throws LimitMetException
+     */
     public function addItem(Item $item): void
     {
+        if ($this->items->count() >= self::ITEMS_LIMIT) {
+            throw new LimitMetException();
+        }
+
         $this->items[] = $item;
     }

Now, all tests are passing:

~/Development/doctrine-playground$ ./vendor/bin/phpunit tests
PHPUnit 11.5.6 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.2.27

..                                                                  2 / 2 (100%)

Time: 00:00.003, Memory: 8.00 MB

OK (2 tests, 3 assertions)

But we have one issue, during adding new item doctrine by default will load whole items collection. It’s not good to load it only to do the count. For that we can use Extra lazy feature from Doctrine ORM:

    /** @var Collection<int, Item> */
    #[ORM\OneToOne(inversedBy: 'createdBy', targetEntity: Item::class, fetch: 'EXTRA_LAZY')]
    private Collection $items;

git diff:

diff --git a/src/User.php b/src/User.php
index d8dcde8..194fe67 100644
--- a/src/User.php
+++ b/src/User.php
@@ -19,7 +19,7 @@ class User
     private string $name;
 
     /** @var Collection<int, Item> */
-    #[ORM\OneToOne(inversedBy: 'createdBy', targetEntity: Item::class)]
+    #[ORM\OneToOne(inversedBy: 'createdBy', targetEntity: Item::class, fetch: 'EXTRA_LAZY')]
     private Collection $items;

Now doctrine won’t load all entities into memory to simply count them, it will do dedicate count SQL query. In that way you can introduce limit for really large entity collections and avoid hitting memory limit and performance issues.

Leave a Reply

Your email address will not be published. Required fields are marked *