Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Ordering Doctrine Collection based on associated Entity when it is not possible to use the @orderBy annotation

I would like to understand the best way to order a Doctrine Collection based on associated Entity. In this case, it is not possible to use the @orderBy annotation.

I have found 5 solutions on the Internet.

1) Adding a method to the AbstractEntity (according to Ian Belter https://stackoverflow.com/a/22183527/1148260)

/**
 * This method will change the order of elements within a Collection based on the given method.
 * It preserves array keys to avoid any direct access issues but will order the elements
 * within the array so that iteration will be done in the requested order.
 *
 * @param string $property
 * @param array  $calledMethods
 *
 * @return $this
 * @throws \InvalidArgumentException
 */
public function orderCollection($property, $calledMethods = array())
{
    /** @var Collection $collection */
    $collection = $this->$property;

    // If we have a PersistentCollection, make sure it is initialized, then unwrap it so we
    // can edit the underlying ArrayCollection without firing the changed method on the
    // PersistentCollection. We're only going in and changing the order of the underlying ArrayCollection.
    if ($collection instanceOf PersistentCollection) {
        /** @var PersistentCollection $collection */
        if (false === $collection->isInitialized()) {
            $collection->initialize();
        }
        $collection = $collection->unwrap();
    }

    if (!$collection instanceOf ArrayCollection) {
        throw new InvalidArgumentException('First argument of orderCollection must reference a PersistentCollection|ArrayCollection within $this.');
    }

    $uaSortFunction = function($first, $second) use ($calledMethods) {

        // Loop through $calledMethods until we find a orderable difference
        foreach ($calledMethods as $callMethod => $order) {

            // If no order was set, swap k => v values and set ASC as default.
            if (false == in_array($order, array('ASC', 'DESC')) ) {
                $callMethod = $order;
                $order = 'ASC';
            }

            if (true == is_string($first->$callMethod())) {

                // String Compare
                $result = strcasecmp($first->$callMethod(), $second->$callMethod());

            } else {

                // Numeric Compare
                $difference = ($first->$callMethod() - $second->$callMethod());
                // This will convert non-zero $results to 1 or -1 or zero values to 0
                // i.e. -22/22 = -1; 0.4/0.4 = 1;
                $result = (0 != $difference) ? $difference / abs($difference): 0;
            }

            // 'Reverse' result if DESC given
            if ('DESC' == $order) {
                $result *= -1;
            }

            // If we have a result, return it, else continue looping
            if (0 !== (int) $result) {
                return (int) $result;
            }
        }

        // No result, return 0
        return 0;
    };

    // Get the values for the ArrayCollection and sort it using the function
    $values = $collection->getValues();
    uasort($values, $uaSortFunction);

    // Clear the current collection values and reintroduce in new order.
    $collection->clear();
    foreach ($values as $key => $item) {
        $collection->set($key, $item);
    }

    return $this;
}

2) Creating a Twig extension, if you need the sorting just in a template (according to Kris https://stackoverflow.com/a/12505347/1148260)

use Doctrine\Common\Collections\Collection;

public function sort(Collection $objects, $name, $property = null)
{
    $values = $objects->getValues();
    usort($values, function ($a, $b) use ($name, $property) {
        $name = 'get' . $name;
        if ($property) {
            $property = 'get' . $property;
            return strcasecmp($a->$name()->$property(), $b->$name()->$property());
        } else {
            return strcasecmp($a->$name(), $b->$name());
        }
    });
    return $values;
}

3) Transforming the collection into an array and then sorting it (according to Benjamin Eberlei https://groups.google.com/d/msg/doctrine-user/zCKG98dPiDY/oOSZBMabebwJ)

public function getSortedByFoo()
{
    $arr = $this->arrayCollection->toArray();
    usort($arr, function($a, $b) {
    if ($a->getFoo() > $b->getFoo()) {
        return -1;
    }
    //...
    });
    return $arr;
}

4) Using ArrayIterator to sort the collection (according to nifr https://stackoverflow.com/a/16707694/1148260)

$iterator = $collection->getIterator();
$iterator->uasort(function ($a, $b) {
    return ($a->getPropery() < $b->getProperty()) ? -1 : 1;
});
$collection = new ArrayCollection(iterator_to_array($iterator));

5) Creating a service to gather the ordered collection and then replace the unordered one (I have not an example but I think it is pretty clear). I think this is the ugliest solution.

Which is the best solution according to you experience? Do you have other suggestions to order a collection in a more effective/elegant way?

Thank you very much.

like image 283
Gianluca78 Avatar asked Apr 03 '14 05:04

Gianluca78


1 Answers

Premise

You proposed 5 valid/decent solutions, but I think that all could be reduced down to two cases, with some minor variants.

We know that sorting is always O(NlogN), so all solution have theoretically the same performance. But since this is Doctrine, the number of SQL queries and the Hydration methods (i.e. converting data from array to object instance) are the bottlenecks.

So you need to choose the "best method", depending on when you need the entities to be loaded and what you'll do with them.

These are my "best solutions", and in a general case I prefer my solution A)

A) DQL in a loader/repository service

Similar to

None of your case (somehow with 5, see the final notes note). Alberto Fernández pointed you in the right direction in a comment.

Best when

DQL is (potentially) the fastest method, since delegate sorting to DBMS which is highly optimized for this. DQL also gives total controls on which entities to fetch in a single query and the hydrations mode.

Drawbacks

It is not possible (AFAIK) to modify query generated by Doctrine Proxy classes by configuration, so your application need to use a Repository and call the proper method every time you load your entities (or override the default one).

Example

class MainEntityRepository extends EntityRepository
{
    public function findSorted(array $conditions)
    {
        $qb = $this->createQueryBuilder('e')
            ->innerJoin('e.association', 'a')
            ->orderBy('a.value')
        ;
        // if you always/frequently read 'a' entities uncomment this to load EAGER-ly
        // $qb->select('e', 'a');

        // If you just need data for display (e.g. in Twig only)
        // return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);

        return $qb->getQuery()->getResult();
    }
}

B) Eager loading, and sorting in PHP

Similar to case

Case 2), 3) and 4) are just the same thing done in different place. My version is a general case which apply whenever the entities are fetched. If you have to choose one of these, then I think that solution 3) is the most convenient, since don't mess with the entity and is always available, but use EAGER loading (read on).

Best when

If the the associated entities are always read, but it is not possible (or convenient) to add a service, then all entities should loaded EAGER-ly. Sorting then can be done by PHP, whenever it makes sense for the application: in an event listener, in a controller, in a twig template... If the entities should be always loaded, then an event listener is the best option.

Drawbacks

Less flexible than DQL, and sorting in PHP may be a slow operation when the collection is big. Also, the entities need to be hydrated as Object which is slow, and is overkill if the collection is not used for other purpose. Beware of lazy-loading, since this will trigger one query for every entity.

Example

MainEntity.orm.xml:

<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping>
  <entity name="MainEntity">
    <id name="id" type="integer" />
    <one-to-many field="collection" target-entity="LinkedEntity" fetch="EAGER" />
    <entity-listeners>
      <entity-listener class="MainEntityListener"/>
    </entity-listeners>
  </entity>
</doctrine-mapping>

MainEntity.php:

class MainEntityListener
{
    private $id;

    private $collection;

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

    // this works only with Doctrine 2.5+, in previous version association where not loaded on event
    public function postLoad(array $conditions)
    {
        /*
         * From your example 1)
         * Remember that $this->collection is an ArryCollection when constructor is called,
         * but a PersistentCollection when are loaded from DB. Don't recreate the instance!
         */

        // Get the values for the ArrayCollection and sort it using the function
        $values = $this->collection->getValues();

        // sort as you like
        asort($values);

        // Clear the current collection values and reintroduce in new order.
        $collection->clear();
        foreach ($values as $key => $item) {
            $collection->set($key, $item);
        }
    }
}

Final Notes

  • I won't use case 1) as is, since is very complicated and introduce inheritance which reduce encapsulation. Also, I think that it has the same complexity and performance of my example.
  • Case 5) is not necessarily bad. If "the service" is the application repository, and it use DQL to sort, then is my first best case. If is a custom service only to sort a collection, then I think is definitely not a good solution.
  • All the codes I wrote here is not ready for "copy-paste", since my objective was to show my point of view. Hope it would be a good starting point.

Disclaimer

These are "my" best solutions, as I do it in my works. Hope will help you and others.

like image 58
giosh94mhz Avatar answered Oct 15 '22 16:10

giosh94mhz