phpobjectdoctrine-ormdoctrine-extensions

Fast entity Doctrine hydrator


I'm looking at improving the speed of doctrine hydration. I've previously been using HYDRATE_OBJECT but can see that in many instances, that can be quite heavy to work with.

I'm aware that the fastest option available is HYDRATE_ARRAY but then I give away a lot of benefits of working with entity objects. In instances where there's business logic in an entity method, that's going to be repeated for however that's handled by arrays.

So what I'm after is a cheaper object hydrator. I'm happy to make some concessions and loose some functionality in the name of speed. For instance if it ended up being read only, that'd be ok. Equally, if lazy loading wasn't a thing, that would be ok too.

Does this sort of thing exist or am I asking too much?


Solution

  • If you want faster ObjectHydrator without losing the ability to work with objects then you will have to create your own custom hydrator.

    To do so you have to do following steps:

    1. Create your own Hydrator class which extends Doctrine\ORM\Internal\Hydration\AbstractHydrator. In my case I am extending ArrayHydrator as it saves me trouble of mapping aliases to object variables:

      use Doctrine\ORM\Internal\Hydration\ArrayHydrator;
      use Doctrine\ORM\Mapping\ClassMetadataInfo;
      use PDO;
      
      class Hydrator extends ArrayHydrator
      {
          const HYDRATE_SIMPLE_OBJECT = 55;
      
          protected function hydrateAllData()
          {
              $entityClassName = reset($this->_rsm->aliasMap);
              $entity = new $entityClassName();
              $entities = [];
              foreach (parent::hydrateAllData() as $data) {
                  $entities[] = $this->hydrateEntity(clone $entity, $data);
              }
      
              return $entities;
          }
      
          protected function hydrateEntity(AbstractEntity $entity, array $data)
          {
              $classMetaData = $this->getClassMetadata(get_class($entity));
              foreach ($data as $fieldName => $value) {
                  if ($classMetaData->hasAssociation($fieldName)) {
                      $associationData = $classMetaData->getAssociationMapping($fieldName);
                      switch ($associationData['type']) {
                          case ClassMetadataInfo::ONE_TO_ONE:
                          case ClassMetadataInfo::MANY_TO_ONE:
                              $data[$fieldName] = $this->hydrateEntity(new $associationData['targetEntity'](), $value);
                              break;
                          case ClassMetadataInfo::MANY_TO_MANY:
                          case ClassMetadataInfo::ONE_TO_MANY:
                              $entities = [];
                              $targetEntity = new $associationData['targetEntity']();
                              foreach ($value as $associatedEntityData) {
                                  $entities[] = $this->hydrateEntity(clone $targetEntity, $associatedEntityData);
                              }
                              $data[$fieldName] = $entities;
                              break;
                          default:
                              throw new \RuntimeException('Unsupported association type');
                      }
                  }
              }
              $entity->populate($data);
      
              return $entity;
          }
      }
      
    2. Register hydrator in Doctrine configuration:

      $config = new \Doctrine\ORM\Configuration()
      $config->addCustomHydrationMode(Hydrator::HYDRATE_SIMPLE_OBJECT, Hydrator::class);
      
    3. Create AbstractEntity with method for populating the entity. In my sample I am using already created setter methods in the entity to populate it:

      abstract class AbstractEntity
      {
          public function populate(Array $data)
          {
              foreach ($data as $field => $value) {
                  $setter = 'set' . ucfirst($field);
                  if (method_exists($this, $setter)) {
                      $this->{$setter}($value);
                  }
              }
          }
      }
      

    After those three steps you can pass HYDRATE_SIMPLE_OBJECT instead of HYDRATE_OBJECT to getResult query method. Keep in mind this implementation was not heavily tested but should work even with nested mappings for more advanced functionality you will have to improve Hydrator::hydrateAllData() and unless you implement connection to EntityManager you will lose the ability to easily save / update entities, while on the other hand because these objects are just mere simple objects, you will be able to serialize and cache them.

    Performance test

    Test code:

    $hydrators = [
        'HYDRATE_OBJECT'        => \Doctrine\ORM\AbstractQuery::HYDRATE_OBJECT,
        'HYDRATE_ARRAY'         => \Doctrine\ORM\AbstractQuery::HYDRATE_ARRAY,
        'HYDRATE_SIMPLE_OBJECT' => Hydrator::HYDRATE_SIMPLE_OBJECT,
    ];
    
    $queryBuilder = $repository->createQueryBuilder('u');
    foreach ($hydrators as $name => $hydrator) {
        $start = microtime(true);
        $queryBuilder->getQuery()->getResult($hydrator);
        $end = microtime(true);
        printf('%s => %s <br/>', $name, $end - $start);
    }
    

    Result based on 940 records with 20~ columns each:

    HYDRATE_OBJECT => 0.57511210441589
    HYDRATE_ARRAY => 0.19534111022949
    HYDRATE_SIMPLE_OBJECT => 0.37919402122498