Skip to content

Instantly share code, notes, and snippets.

@Arkemlar
Last active January 24, 2021 00:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Arkemlar/d1dca641e9f6932d37d052af3265a71d to your computer and use it in GitHub Desktop.
Save Arkemlar/d1dca641e9f6932d37d052af3265a71d to your computer and use it in GitHub Desktop.
Custom doctirne collections implementation. Usage examples shown in comment below. Later I will make a repo when have time for it.
<?php
namespace Your\Namespace;
use Closure;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\Common\Collections\Criteria;
use InvalidArgumentException;
/**
* Dreamed about custom doctrine collections? Here it is!
* PHP v5.5 + Doctrine\Common lib (obviously) are required.
*
* Short description:
*
* This is the base abstract class which is used to create custom collections.
* Custom collection enables you a way to add custom element's getters/filters ($collection->getEnabledItems()), custom setters and so on
* BUT these custom methods should internally use proxy methods defined in this abstract class, avoid using $this->collection directly.
*
* This extended collection cares about element's type, it will not allow adding new element of invalid type (meaning instance of invalid class name). The type is defined in the second constructor argument as FQCN string.
*
* This extended collection designed to fully support indexes (so add() method will care about index and will not just stupidly push
* new element to the end of elements array using [] operator as it does in ArrayCollection class), to achieve this you need to specify
* closure as third constructor argument, it will be used to get index for new element. If null value given to third argument then collection
* is treated as non-indexed and behaves like ArrayCollection.
*
* @author Yanosh F. // github.com/Arkemlar
*/
abstract class AbstractExtendedCollection implements Collection, Selectable
{
/** @var Collection|Selectable Wrapped collection */
protected $collection;
/** @var string Type of collection elements */
protected $type;
/** @var Closure Function used to get value for collection's index from element */
protected $getIndexValueClosure;
/**
* Collection constructor.
* It is not public because I suggest to implement your custom static constructor rather than override this one.
* However it's up to you how to access constructor, ofc you could override it and make it public in your subclass implementation.
*
* @param Collection $collection Wrapped collection
* @param string $type Type of collection elements as FQCN
* @param Closure|null $getIndexValueClosure Function used to get value for index from element,
* accepts element and should return the same field's value as specified in Doctrine's
* indexBy mapping. Returned value type should be integer or string.
* Null value for this argument means that collection is not indexed.
*
* @throws InvalidArgumentException
*/
protected function __construct(Collection $collection, $type, Closure $getIndexValueClosure = null)
{
if (!is_string($type) || !class_exists($type)) {
throw new InvalidArgumentException(sprintf('The $type argument should be FQCN string, %s given',
is_string($type) ? "\"$type\"" : gettype($type) ));
}
// Check that collection's objects are of correct type. Since collection is used with doctrine, we only check first argument
// assuming that other objects are instances of the same class.
// NOTICE: If your collection is expected to be big, this may cause unwanted collection loading. Override this as you need.
if (!$collection->isEmpty() && !$collection->first() instanceof $type) {
throw new InvalidArgumentException(
sprintf(
'It seems like provided $collection contains objects of %s class
which is invalid because the $type is set to %s (so only objects of this class allowed)',
get_class($collection->first()),
$type
)
);
}
$this->type = $type;
$this->collection = $collection;
$this->getIndexValueClosure = $getIndexValueClosure;
}
/**
* Replace array elements
*
* @param array $newArray
*/
public function replaceArray($newArray)
{
$this->clear();
foreach ($newArray as $item) {
$this->add($item); // Type check executed inside add() method
}
}
/**
* Get type of collection elements (FQCN)
*
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* Check that $value has valid type (is instance of collection's type represented as FQCN)
*
* @param $value
*
* @throws InvalidArgumentException If type of the value is not correct
*/
protected function checkType($value)
{
if (!$value instanceof $this->type) {
throw new InvalidArgumentException(
sprintf(
"This extended collection supports only objects of %s class, but %s given",
$this->type,
get_class($value)
)
);
}
}
/**
* Whether this collection has special indexes or not
*
* @return bool
*/
protected function isIndexed()
{
if (null === $this->getIndexValueClosure) {
return false;
}
return true;
}
/**
* Get value from $element that will be used as element's index
*
* @param $element
*
* @return string|integer
* @throws \RuntimeException If closure returned value of invalid type (it must be string or integer)
*/
protected function getIndexValue($element)
{
$index = call_user_func($this->getIndexValueClosure, $element);
if(is_string($index) || is_integer($index))
return $index;
throw new \RuntimeException(sprintf('Index value should be of type string or integer, but %s returned by closure', gettype($index)));
}
// ============ Proxy methods ======================
/**
* {@inheritDoc}
*/
public function toArray()
{
return $this->collection->toArray();
}
/**
* {@inheritDoc}
*/
public function first()
{
return $this->collection->first();
}
/**
* {@inheritDoc}
*/
public function last()
{
return $this->collection->last();
}
/**
* {@inheritDoc}
*/
public function key()
{
return $this->collection->key();
}
/**
* {@inheritDoc}
*/
public function next()
{
return $this->collection->next();
}
/**
* {@inheritDoc}
*/
public function current()
{
return $this->collection->current();
}
/**
* {@inheritDoc}
*/
public function remove($key)
{
return $this->collection->remove($key);
}
/**
* {@inheritDoc}
*/
public function removeElement($element)
{
$this->checkType($element);
return $this->collection->removeElement($element);
}
/**
* {@inheritDoc}
*/
public function offsetExists($offset)
{
return $this->collection->offsetExists($offset);
}
/**
* {@inheritDoc}
*/
public function offsetGet($offset)
{
return $this->collection->offsetGet($offset);
}
/**
* {@inheritDoc}
*/
public function offsetSet($offset, $value)
{
$this->checkType($value);
$this->collection->offsetSet($offset, $value);
}
/**
* {@inheritDoc}
*/
public function offsetUnset($offset)
{
return $this->collection->offsetUnset($offset);
}
/**
* {@inheritDoc}
*/
public function containsKey($key)
{
return $this->collection->containsKey($key);
}
/**
* {@inheritDoc}
*/
public function contains($element)
{
$this->checkType($element);
return $this->collection->contains($element);
}
/**
* {@inheritDoc}
*/
public function exists(Closure $p)
{
return $this->collection->exists($p);
}
/**
* {@inheritDoc}
*/
public function indexOf($element)
{
$this->checkType($element);
return $this->collection->indexOf($element);
}
/**
* {@inheritDoc}
*/
public function get($key)
{
return $this->collection->get($key);
}
/**
* {@inheritDoc}
*/
public function getKeys()
{
return $this->collection->getKeys();
}
/**
* {@inheritDoc}
*/
public function getValues()
{
return $this->collection->getValues();
}
/**
* {@inheritDoc}
*/
public function count()
{
return $this->collection->count();
}
/**
* {@inheritDoc}
*/
public function set($key, $value)
{
$this->checkType($value);
$this->collection->set($key, $value);
}
/**
* {@inheritDoc}
*/
public function add($value)
{
$this->checkType($value);
if ($this->isIndexed()) { // Indexed collection, use native set()
$this->collection->set($this->getIndexValue($value), $value);
}
else { // Not indexed collection, use native add()
$this->collection->add($value);
}
return true;
}
/**
* {@inheritDoc}
*/
public function isEmpty()
{
return $this->collection->isEmpty();
}
/**
* {@inheritDoc}
*/
public function getIterator()
{
return $this->collection->getIterator();
}
/**
* {@inheritDoc}
*/
public function map(Closure $func)
{
return $this->collection->map($func);
}
/**
* {@inheritDoc}
*/
public function filter(Closure $p)
{
return $this->collection->filter($p);
}
/**
* {@inheritDoc}
*/
public function forAll(Closure $p)
{
return $this->collection->forAll($p);
}
/**
* {@inheritDoc}
*/
public function partition(Closure $p)
{
return $this->collection->partition($p);
}
/**
* {@inheritDoc}
*/
public function clear()
{
$this->collection->clear();
}
/**
* {@inheritDoc}
*/
public function slice($offset, $length = null)
{
return $this->collection->slice($offset, $length);
}
/**
* {@inheritDoc}
*/
public function matching(Criteria $criteria)
{
return $this->collection->matching($criteria);
}
}
<?php
namespace Your\Namespace;
use Closure;
use Doctrine\Common\Collections\Collection;
class CommonExtendedCollection extends AbstractExtendedCollection
{
public static function newInstance(Collection $collection, $type, Closure $getIndexValueClosure = null)
{
return new static($collection, $type, $getIndexValueClosure);
}
}
@Arkemlar
Copy link
Author

Arkemlar commented Apr 28, 2017

How to use

Basic example

Simple indexed collection example:

class Product
{
    /**
     * @var ArrayCollection|ProductAttribute[]
     *
     * @ORM\OneToMany(targetEntity="Your\Namespace\Entity\ProductAttribute", mappedBy="product",
     *     indexBy="name", cascade={"persist"}, orphanRemoval=true)
     */
    protected $productAttributes;
    /** @var CommonExtendedCollection  */
    protected $productAttributesExtendedCollection = null;

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

    // It is getter & setter at the same time. I dont like to have 3-5 methods which doing nothing than just proxying collection's methods
    public function productAttributes($newArray = null)
    {
        if(null === $this->productAttributesExtendedCollection)
            $this->productAttributesExtendedCollection = CommonExtendedCollection::newInstance(
                $this->productAttributes,
                ProductAttribute::class,
                function (ProductAttribute $item) {
                    return $item->getName();  // getName() returns the value of 'name' column in ProductAttribute's table, so it is comaptible with indexBy option value
                });
    
        if ($newArray)
            $this->productAttributesExtendedCollection->replaceArray($newArray);
        
        return $this->productAttributesExtendedCollection;
    }
}

Then use it just like ArrayCollection:

$product = new Product();
$productProperty = new ProductProperty('property name', 'property value');
$product->productAttributes()->add($productProperty);
// add() method cares about index:
$productProperty == $product->productAttributes()->get('property name');
// easaly replace properties colleciton:
$product->productAttributes($otherProduct->productAttributes()->toArray());
// throws an exception when adding invalid object to collection:
$product->productAttributes()->add($notProductProperty);

Advanced example

Now lets use ID property of entity referenced from ProductAttribute as index:
SomeEntity referenced by ProductAttribute via ManyToOne undirectional mapping:

class Product
{
    /**
     * @Assert\NotNull()
     * @ORM\ManyToOne(targetEntity="Your\Namespace\Entity\SomeEntity")
     * @ORM\JoinColumn(name="some_entity_id", referencedColumnName="id", nullable=false)
    * Notice: name="some_entity_id" and nullable=false - it is important to exactly define join column name and ensure it is not null
     */
    protected $foodComponent;
}
class Product
{
    /**
     * @var ArrayCollection|ProductAttribute[]
     *
     * @ORM\OneToMany(targetEntity="Your\Namespace\Entity\ProductAttribute", mappedBy="product",
     *     indexBy="some_entity_id", cascade={"persist"}, orphanRemoval=true)
     * Notice: indexBy="some_entity_id" is the same as name="some_entity_id" above
     */
    protected $productAttributes;
    /** @var CommonExtendedCollection  */
    protected $productAttributesExtendedCollection = null;

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

    public function productAttributes($newArray = null)
    {
        if(null === $this->productAttributesExtendedCollection)
            $this->productAttributesExtendedCollection = CommonExtendedCollection::newInstance(
                $this->productAttributes,
                ProductAttribute::class,
                function (ProductAttribute $item) {
                    // Here we get ID of SomeEntity, it is useful when SomeEntity has meaningful string id, 
                    // you must ensure that indexBy option value in productAttributes property mapping is the same as foreign key field name
                    // in ProductAttribute's database table (some_entity_id in this case)
                    return $item->getSomeEntity()->getIdField();
                });
    
        if ($newArray)
            $this->productAttributesExtendedCollection->replaceArray($newArray);
        
        return $this->productAttributesExtendedCollection;
    }
}

Reusable collections for specific class interface

Write coustom collection class extending from AbstractExtendedCollection:

class UsersCollection extends AbstractExtendedCollection
{
    const COLLECTION_TYPE = SomeUserInterface::class;  // Collection elements should implement this interface
    
    public static function newInstance(Collection $collection)
    {
        return new static($collection, static::COLLECTION_TYPE, function (SomeUserInterface $item) {
            return $item->getCode();
        });
    }
    
    public function filterByGroupsAndStatus($groups = [], $status = null)
    {
        if (is_null($status)) {
            // Filter by group only
            $callback = function (SomeUserInterface $user) use ($groups) {
                return
                    true == in_array($user->getGroup(), $groups);
            };
        } else {
            // Filter by both groups and status
            $callback = function (SomeUserInterface $user) use ($groups, $status) {
                return
                    true == in_array($user->getGroup(), $groups)
                    &&
                    $user->getStatus() == $status;
            };
        }
        
        return $this->filter($callback);
    }
}

Then use collection:

class WhateverEntity
{
    protected $users;
    protected $usersExtendedCollection;
    public function users($newArray = null)
    {
        if(null === $this->users)
            $this->usersExtendedCollection = UsersCollection::newInstance($this->users);
    
        if ($newArray)
            $this->usersExtendedCollection->replaceArray($newArray);
        
        return $this->usersExtendedCollection;
    }
}

Tags: #symfony, #doctrine, #collection

@tuancode
Copy link

tuancode commented Sep 2, 2020

Hi @Arkemlar,
I'm sorry but could you please explain the purpose of the extended collection field inside an entity?
I didn't get that point. Can I just simple override the original collection field?

@Axxon
Copy link

Axxon commented Jan 23, 2021

Thx a lot ! Nice clue. Better than just using ArrayCollection that completely break down my design. Unfortunately doctrine3 will not permit to extends Persistence collection so we have to deal with that hack.

@Arkemlar
Copy link
Author

Hi @Arkemlar,
I'm sorry but could you please explain the purpose of the extended collection field inside an entity?
I didn't get that point. Can I just simple override the original collection field?

You must keep original ArrayCollection field because doctrine knows nothing about extended collection and will always instantiate ArrayCollection or its proxy. So you must have original field to not break doctrine, and another field to keep extended collection instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment