zend-framework2zend-framework3zend-servicemanagerzend-hydrator

ZF FactoryInterface - using options parameter for configuring loading dependencies


I am wondering about the best practices for loading complex objects. To begin with, i'm going to outline some boilerplate before getting to the problem. Assume the following: A simple domain model Client is loaded using a tablegateway, with factories used at every stage to inject dependencies:

namespace My\Model\Client;
class Client implements InputFilterProviderInterface
{
    /**@var integer*/
    protected $id;
    /**@var InputFilter*/
    protected $inputFilter;
    /**@var Preferences */
    protected $preferences;
    /**@var Orders*/
    protected $orders;
    /**@var Contacts*/
    protected $contacts;      
}

A factory for this Client object:

namespace My\Model\Client;
class ClientFactory implements FactoryInterface
{
    public function($container, $requestedName, $options)
    {
        $client = new Client();
        $client->setInputFilter($container->get('InputFilterManager')->get('ClientInputFilter'));
        return $client;
    }
}

Next the mapper factory, which uses a TableGateway:

namespace My\Model\Client\Mapper;
class ClientMapperFactory implements FactoryInterface
{
     public function __invoke($container, $requestedName, $options)
     {
        return new ClientMapper($container->get(ClientTableGateway::class));
     }
}

The TableGatewayFactory:

namespace My\Model\Client\TableGateway
class ClientTableGatewayFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $hydrator = new ArraySerialisable();
        $rowObjectPrototype = $container->get(Client::class);
        $resultSet = new HydratingResultSet($hydrator, $rowObjectPrototype);
        $tableGateway = new  TableGateway('clients', $container->get(Adapter::class), null, $resultSet);
        return $tableGateway;

Note the use of a HydratingResultset to return fully formed Client objects from the ResultSet. This all works nicely. Now the Client object has several related objects as properties, so whilst using the HydratingResultSet, i'm going to add an AggregateHydrator to load them:

class ClientTableGatewayFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        **$hydrator = $container->get('HydratorManager')->get(ClientHydrator::class);**
        $rowObjectPrototype = $container->get(Client::class);
        $resultSet = new HydratingResultSet($hydrator, $rowObjectPrototype);
        $tableGateway = new  TableGateway('clients', $container->get(Adapter::class), null, $resultSet);
        return $tableGateway;
    }

Finally, the Clients hydrator factory:

class ClientHydratorFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        //base ArraySerializable for Client object hydration
        $arrayHydrator = new ArraySerializable();
        $arrayHydrator->addStrategy('dateRegistered', new DateTimeStrategy());

        $aggregateHydrator = new AggregateHydrator();
        $aggregateHydrator->add($arrayHydrator);

        $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsAddressHydrator::class));
        $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsOrdersHydrator::class));
        $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsPreferencesHydrator::class));
        $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsContactsHydrator::class));
        return $aggregateHydrator;
    }
}

... with the gist of the above hydrators being like:

class ClientsAddressHydrator implements HydratorInterface
{
    /** @var AddressMapper */
    protected $addressMapper;

    public function __construct(AddressMapper $addressMapper){
        $this->addressMapper = $addressMapper;
    }

    public function extract($object){return $object;}

    public function hydrate(array $data, $object)
    {
        if(!$object instanceof Client){
            return;
        }

        if(array_key_exists('id', $data)){
            $address = $this->addressMapper->findClientAddress($data['id']);
            if($address instanceof Address){
                $object->setAddress($address);
            }
        }
        return $object;
    }
}

Finally we're at the issue. The above works perfectly and will load quite cleanly a Client object with all the related objects fully formed. But i have some resources where the entire object graph is not needed - for instance, when viewing a table of all clients - there is no need for any more information to be loaded.

So i've been thinking of ways of using the factories to choose which dependencies to include.

Solution 1 A factory for each use case. If only the Client data is needed (with no dependencies), then create a series of factories ie ClientFactory, SimpleClientFactory, ComplexClientFactory, ClientWithAppointmentsFactory etc. Seems redundant and not very reusable.

Solution 2 Use the options param defined in the FactoryInterface to pass "loading" options to the hydrator factory, eg:

  class ViewClientDetailsControllerFactory implements FactoryInterface
    {
         //all Client info needed - full object graph
         public function __invoke($container, $requestedName, $options)
         {
            $controller = new ViewClientDetailsController();
            $loadDependencies = [
                'loadPreferences' => true,
                'loadOrders' => true,
                'loadContacts' => true
             ];
            $clientMapper = $container->get(ClientMapper::class, '', $loadDependencies);
            return $controller;
         }
    }



   class ViewAllClientsControllerFactory implements FactoryInterface
    {
         //Only need Client data - no related objects
         public function __invoke($container, $requestedName, $options)
         {
            $controller = new ViewAllClientsController();
            $loadDependencies = [
                'loadPreferences' => false,
                'loadOrders' => false,
                'loadContacts' => false
             ];
            $clientMapper = $container->get(ClientMapper::class, '', $loadDependencies);
            return $controller;
         }
    }

The mapper factory passes the options to the tablegateway factory, that passes them on to the hydrator factory:

class ClientTableGatewayFactory implements FactoryInterface
{
     public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $hydrator = $container->get('HydratorManager')->get(ClientHydrator::class, '', $options);
        $rowObjectPrototype = $container->get(Client::class);
        $resultSet = new HydratingResultSet($hydrator, $rowObjectPrototype);
        $tableGateway = new  TableGateway('clients', $container->get(Adapter::class), null, $resultSet);
        return $tableGateway;
}

Finally, we can define here how much info to load into the Client:

class ClientHydratorFactory implements FactoryInterface
    {
        public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
        {
            //base ArraySerializable for Client object hydration
            $arrayHydrator = new ArraySerializable();
            $arrayHydrator->addStrategy('dateRegistered', new DateTimeStrategy());

            $aggregateHydrator = new AggregateHydrator();
            $aggregateHydrator->add($arrayHydrator);
            if($options['loadAddress'] === true){
                   $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsAddressHydrator::class));            
            }
            if($options['loadOrders'] === true){
                $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsOrdersHydrator::class));
            }
            if($options['loadPreferences'] === true){
                $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsPreferencesHydrator::class));
            }
            if($options['loadContacts'] === true){
                $aggregateHydrator->add($container->get('HydratorManager')->get(ClientsContactsHydrator::class));
            }
            return $aggregateHydrator;
        }
    }

This seems to be a clean solution, as the dependencies can be defined per request. However i don't think that this is using the options param as intended - the documentation states that this parameter is supposed to be for passing constructor params to the object, not defining what logic the factory should use to load dependencies.

Any advice, or alternative solutions to achieve the above, would be great. Thanks for reading.


Solution

  • Creating a big palette of all possible combinations would not be just a nightmare, but a declared suicide.

    Using options

    I wouldn't suggest you this option either. I mean, it's not that bad, but it has a major issue: everytime you instantiate your hydrator, you should remember to pass those options, or you'll get an "empty hydrator". Same logic applies to everything that uses those hydrators.

    Since you actually want to remove hydrators you don't need, I'd suggest to avoid this solution, because this way you are always forced to declare which hydrators you need (and, honestly, I'll always forget to do it.. ^^ ). If you add a new hydrator, you'll have to go through your project and add new options. Not really worth the effort...

    That's why I propose you the next solution

    Removing unnecessary hydrators

    In 99% of the cases, hydrators are used by mappers. Thus, I think it would be cleanier to have a mapper which, by default, returns always the same kind of data (->a single hydrator), but that it can be modified to remove a certain set of hydrators.

    Inside the AggregateHydrator, all hydrators are converted into listeners and attached to EventManager. I had some issue while trying to get all events, so I turned on creating an aggregate hydrator with the option to detach an hydrator:

    class DetachableAggregateHydrator extends AggregateHydrator 
    {
    
        /**
         * List of all hydrators (as listeners)
         *
         * @var array
         */
        private $listeners = [];
    
        /**
         * {@inherit}
         */
        public function add(HydratorInterface $hydrator, int $priority = self::DEFAULT_PRIORITY): void 
        {
            $listener = new HydratorListener($hydrator);
            $listener->attach($this->getEventManager(), $priority);
    
            $this->listeners[get_class($hydrator)] = $listener;
    
        }
    
        /**
         * Remove a single hydrator and detach its listener
         * 
         * @param string $hydratorClass
         */
        public function detach($hydratorClass) 
        {
            $listener = $this->listeners[$hydratorClass];
            $listener->detach($this->getEventManager());
            unset($listener);
            unset($this->listeners[$hydratorClass]);
    
        }
    
    }
    

    Then, in the TableGatewayFactory:

    class ClientTableGatewayFactory implements FactoryInterface
    {
        public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
        {
            $hydrator = $container->get('HydratorManager')->get(ClientHydrator::class);
            $rowObjectPrototype = $container->get(Client::class);
            $resultSet = new HydratingResultSet($hydrator, $rowObjectPrototype);
            $adapter = $container->get(Adapter::class);
            $tableGateway = new  TableGateway('clients', $adapter, null, $resultSet);
            return $tableGateway;
        }
    
    }
    

    And the ClientHydratorFactory:

    class ClientHydratorFactory implements FactoryInterface
    {
        public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
        {
            $aggregateHydrator = new DetachableAggregateHydrator();
    
            $arrayHydrator = new ArraySerializable();
            $arrayHydrator->addStrategy('dateRegistered', new DateTimeStrategy());
            $aggregateHydrator->add($arrayHydrator);
    
            $hydratorManager = $container->get('HydratorManager');
            $aggregateHydrator->add($hydratorManager->get(ClientsAddressHydrator::class));
            $aggregateHydrator->add($hydratorManager->get(ClientsOrdersHydrator::class));
            $aggregateHydrator->add($hydratorManager->get(ClientsPreferencesHydrator::class));
            $aggregateHydrator->add($hydratorManager->get(ClientsContactsHydrator::class));
    
            return $aggregateHydrator;
        }
    }
    

    You just need to make tablegateway accessible by outstide the mapper:

    class ClientMapper 
    {
    
        private $tableGateway;
    
        // ..
        // Other methods
        // ..
    
        public function getTableGateway(): TableGateway 
        {
            return $this->tableGateway;
        }
    }
    

    And now you're able to choose which hydrators you don't want to attach.

    Let's say you have two controllers:

    Their factories will be:

    class ClientInfoController implements FactoryInterface
    {
        public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
        {
            $clientMapper = $container->get(ClientMapper::class);
    
            // Orders are unnecessary
            $resultSetPrototype = $clientMapper->getTableGateway()->getResultSetPrototype();
            $resultSetPrototype->getHydrator()->detach(ClientsOrdersHydrator::class);
    
            return $aggregateHydrator;
        }
    }
    
    class ClientOrdersController implements FactoryInterface
    {
        public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
        {
            $clientMapper = $container->get(ClientMapper::class);
    
            // Orders are unnecessary
            $resultSetPrototype = $clientMapper->getTableGateway()->getResultSetPrototype();
            $resultSetPrototype->getHydrator()->detach(ClientsAddressHydrator::class);
            $resultSetPrototype->getHydrator()->detach(ClientsPreferencesHydrator::class);
            $resultSetPrototype->getHydrator()->detach(ClientsContactsHydrator::class);
    
            return $aggregateHydrator;
        }
    }