Skip to main content

ArrayCollection

Modification to Doctrine's ArrayCollection, so that it does matching() against the private properties directly, not against the return values of their getters.

Installation

composer require rekalogika/collections-domain

Usage

Simply substitute Doctrine\Common\Collections\ArrayCollection with Rekalogika\Domain\Collections\ArrayCollection:

- use Doctrine\Common\Collections\ArrayCollection;
+ use Rekalogika\Domain\Collections\ArrayCollection;

It should be safe to do a mass find-and-replace in all of your entities.

Description

Doctrine entities usually initialize a Collection using ArrayCollection in their constructors. But when the entity is hydrated from the database, Doctrine ORM will inject a PersistentCollection directly into the property.

If we call matching() against the PersistentCollection, it will be done against the related entities' private properties directly. While ArrayCollection does it against the return values of the getters.

Therefore, there will be a 'mismatching' in the collection's behavior between when the owning entity is new and not yet persisted, and after it is hydrated from the database.

The problem happens when, for example:

  • The property does not have a getter, or
  • The getter returns a different value or different type from the property's value, or
  • The getter contains business logic, and does not return the property's value as is.

The problem usually happens with new, not-yet-persisted entities, and in unit tests where the tests don't involve the database.

Our ArrayCollection changes the behavior so that it does the matching() against the private properties directly, so that both Collections will have the same behavior.

Limitation

This problem will also happen when fetch set to EAGER, or when the collection is initialized before the matching() is called. Unfortunately, it is impossible to work around this problem outside Doctrine.

However, if you can afford to fetch the collection eagerly, then you can afford to use filter() instead. Unlike matching(), filter() is always consistent in every cases.

Example

The following classes implement the null object pattern. If the nationality property is null, it will return an instance of Stateless instead of null:

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class Country {}
class Stateless extends Country {}

class Player
{
public function __construct(
private string $name,
private Team $team,
private ?Country $nationality = null,
) {
}

public function getName(): string
{
return $this->name;
}

public function getTeam(): Team
{
return $this->team;
}

public function getNationality(): Country
{
return $this->country ?? new Stateless();
}
}

class Team
{
/** @var Collection<int,Player> */
private Collection $players;

public function __construct()
{
$this->players = new ArrayCollection();
}

/**
* @return Collection<int,Player>
*/
public function getPlayers(): Collection
{
return $this->players;
}

public function getStatelessPlayers(): Collection
{
return $this->players->matching(
Criteria::create()
->where(Criteria::expr()->isNull('nationality'))
);
}

If the Team is not yet persisted, the getStatelessPlayers() method will incorrectly return an empty collection every time. But if the Team object is hydrated from the database, it will correctly return the players without a nationality.

Changing the code to use Rekalogika\Domain\Collections\ArrayCollection will resolve the problem.