Search code examples
doctrine-ormzend-framework2doctrine-orm-filters

ZF2 + Doctrine 2 - Use a Factory to create a Doctrine SQLFilter - How?


I figured it out, answer is below - Leaving the question and all the process stuff here in case it might help someone figure out the same in the future, though most of it is made redundant by the answer.


What I'm trying to do is, filter data based on the Company that a User is associated with (User#company).

I have a few Entities for this scenario:

  • User
  • Company
  • Address

The scenario is that data, in this case Address Entity objects, are created by User Entities. Each User has a Company Entity that it belongs to. As such, each Address has a Address#createdByCompany property.

Now I'm trying to create a SQLFilter extension, as described by the Doctrine docs - "Working with Filters".

I've created the following class:

class CreatedByCompanyFilter extends SQLFilter
{
    /**
     * @var Company
     */
    protected $company;

    /**
     * @param ClassMetadata $targetEntity
     * @param string $targetTableAlias
     * @return string
     * @throws TraitNotImplementException
     */
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
    {
        // Check if Entity implements CreatedByCompanyAwareInterface, if not, return empty string
        if (!$targetEntity->getReflectionClass()->implementsInterface(CreatedByCompanyInterface::class)) {

            return '';
        }

        if (!array_key_exists(CreatedByCompanyTrait::class, $targetEntity->getReflectionClass()->getTraits())) {

            throw new TraitNotImplementException(
                ($targetEntity->getReflectionClass()->getName()) . ' requires "' . CreatedByCompanyTrait::class . '" to be implemented.'
            );
        }

        return $targetTableAlias . '.created_by_company = ' . $this->getCompany()->getId();
    }

    /**
     * @return Company
     */
    public function getCompany(): Company // Using PHP 7.1 -> explicit return types
    {
        return $this->company;
    }

    /**
     * @param Company $company
     * @return CreatedByCompanyFilter
     */
    public function setCompany(Company $company): CreatedByCompanyFilter
    {
        $this->company = $company;
        return $this;
    }
}

To use this filter, it's registered in the config and setup to be loaded in the modules Bootstrap (onBootstrap). So far so good, the above gets used.

However, the above is not used via the Factory Pattern. Also, you might notice that the addFilterConstraint(...) uses $this->getCompany()->getId(), but $this->setCompany() isn't called anywhere, creating a function call on a null value.

How can I use a Factory to create this class, either using the normal route of the ZF2 ServiceManager or via a registration of Doctrine itself?


What I've already tried

Next to Google'ing a lot for the past few hours, trying to find a solution, I've also tried the following

1 - ZF2 Factory

Using the default ZF2 method of registering the Factory in config, does not work:

'service_manager' => [
    'factories' => [
        CreatedByCompanyFilter::class => CreatedByCompanyFilterFactory::class,
    ],
],

The Factory simply never gets called. This could have something to do with the order of execution. I'm thinking Doctrine SQLFilters get set before the ServiceManager is fully up and running, just in case of a scenario I'm trying to do: filtering data for a user based on some role-based stuff (or "company-stuff" in this case).

2 - Ocramius's ZF2 Delegator Factories

While working on this I found Ocramius's Delegator Factories. Very interesting stuff, definitely worth a read and works nicely. However, not for my scenario. I followed his guide and created a CreatedByCompanyFilterDelegatorFactory. This I registered in the config, but had no result, the actual Factory never gets called.

(sorry, removed code already)


The Factory I'm trying to run Updated as *ListenerFactory, see 'Currently trying' below

class CreatedByCompanyListenerFactory implements FactoryInterface
{
    // NOTE: REMOVED DOCBLOCKS/COMMENTS FOR LESS CODE
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $authService = $serviceLocator->get('zfcuser_auth_service');
        $user = $authService->getIdentity();

        $listener = new CreatedByCompanyListener();
        $listener->setCompany($user->getCompany());

        return $listener;
    }
}

Currently trying

I figured I could try to take a page out of the Gedmo Extensions and Doctrine Events playbook and use the format of hooking a Listener onto the loadClassMetadata Event.

As an example, Gedmo's SoftDeleteable has the following config within Doctrine's config to make it work:

'eventmanager' => [
    'orm_default' => [
        'subscribers' => [
            SoftDeleteableListener::class,
        ],
    ],
],
'configuration' => [
    'orm_default' => [
        'filters' => [
            'soft-deleteable' => SoftDeleteableFilter::class,
        ],
    ],
],

So I figured, hell, let's try that, and setup the following:

'eventmanager' => [
    'orm_default' => [
        'subscribers' => [
            CreatedByCompanyListener::class,
        ],
    ],
],
'configuration' => [
    'orm_default' => [
        'filters' => [
            'created-by-company' => CreatedByCompanyFilter::class,
        ],
    ],
],
'service_manager' => [
    'factories' => [
        CreatedByCompanyListener::class => CreatedByCompanyListenerFactory::class,
    ],
],

The purpose would be to use the *ListenerFactory to get the authenticated User Entity into the *Listener. The *Listener in turn would pass on the Company associated with the User into the EventArgs passed along to the CreatedByCompanyFilter. That would, theoretically, having that object should be available.

The CreatedByCompanyLister is, for now, the following:

class CreatedByCompanyListener implements EventSubscriber
{
    //NOTE: REMOVED DOCBLOCKS/HINTS FOR LESS CODE
    protected $company;

    public function getSubscribedEvents()
    {
        return [
            Events::loadClassMetadata,
        ];
    }

    public function loadClassMetadata(EventArgs $eventArgs)
    {
        $test = $eventArgs; // Debug line, not sure on what to do if this works yet ;) 
    }

    // $company getter/setter
}

However, I'm getting stuck on the Zend\Authentication\AuthenticationService used in the *ListenerFactory, it throws an exception when trying to get the identity of the User with $user = $authService->getIdentity();.

Internally this function continues into ZfcUser\Authentication\Storage\Db class, line 70, ($identity = $this->getMapper()->findById($identity);), where it crashes, throwing on getMapper():

Zend\ServiceManager\ServiceManager::get was unable to fetch or create an instance for doctrine.entitymanager.orm_default

Stepping over (xdebug in PhpStorm) that call somehow lets it continue for a bit further (bit of a wtf...), but then it throws:

An exception was raised while creating "zfcuser_user_mapper"; no instance returned

With a "*Exception*previous" that says:

Circular dependency for LazyServiceLoader was found for instance Doctrine\ORM\EntityManager

Independently I know what all of these errors mean, but I've never seen all of 'em get thrown by the same function call (->getMapper()) before. Any ideas?


Solution

  • I was, of course, thinking wayyyyyy too complicated. I've now got it running using the following classes:

    • CreatedByCompanyFilter
    • CreatedByCompanyListener
    • CreatedByCompanyListenerFactory

    The only config that I needed was the following:

    'listeners' => [
        CreatedByCompanyListener::class,
    ],
    'service_manager' => [
        'factories' => [
            CreatedByCompanyListener::class => CreatedByCompanyListenerFactory::class,
        ],
    ],
    

    Note: did not register the filter in the Doctrine config. So do not do below if you wish to do the same!

    'doctrine' => [
        'eventmanager' => [
            'orm_default' => [
                'subscribers' => [
                    CreatedByCompanyListener::class,
                ],
            ],
        ],
        'configuration' => [
            'orm_default' => [
                'filters' => [
                    'created-by-company' => CreatedByCompanyFilter::class,
                ],
            ],
        ],
    ],
    

    Repeat: the above is not needed in this scenario - Though seeing what I've been up to, I can see how I (and others) might assume that it might be.


    So, just 3 classes, one of which a registered Listener with a registered ListenerFactory in the ServiceManager config.

    The logic has ended up being that the Listener needs to enable the Filter in with the Doctrine EntityManager, after which it is possible to set required parameters in the return value (the filter). Not sure if "Daredevel" has a Stackoverflow account, but thanks for this article! in which I noticed the enabling of a filter, following by setting its params.

    So, the Listener enables the Filter and sets its params.

    The CreatedByCompanyListener is as follows:

    class CreatedByCompanyListener implements ListenerAggregateInterface
    {
        // NOTE: Removed code comments & docblocks for less code. Added inline for clarity.
    
        protected $company;         // Type Company entity
        protected $filter;          // Type CreatedByCompanyFilter
        protected $entityManager;   // Type EntityManager|ObjectManager
        protected $listeners = [];  // Type array
    
        public function attach(EventManagerInterface $events)
        {
            $this->listeners[] = $events->attach(MvcEvent::EVENT_BOOTSTRAP, [$this, 'onBootstrap'], -5000);
        }
    
        public function detach(EventManagerInterface $events)
        {
            foreach ($this->listeners as $index => $listener) {
                if ($events->detach($listener)) {
                    unset($this->listeners[$index]);
                }
            }
        }
    
        public function onBootstrap(MvcEvent $event)
        {
            $this->getEntityManager()->getConfiguration()->addFilter('created-by-company', get_class($this->getFilter()));
            $filter = $this->getEntityManager()->getFilters()->enable('created-by-company');
    
            $filter->setParameter('company', $this->getCompany()->getId());
        }
    
        // Getters & Setters for $company, $filter, $entityManager
    }
    

    The CreatedByCompanyListenerFactory is as follows:

    class CreatedByCompanyListenerFactory implements FactoryInterface { public function createService(ServiceLocatorInterface $serviceLocator) { $authService = $serviceLocator->get('zfcuser_auth_service'); $entityManager = $serviceLocator->get(EntityManager::class); $listener = new CreatedByCompanyListener();

        $user = $authService->getIdentity();
        if ($user instanceof User) {
            $company = $user->getCompany();
        } else {
            // Check that the database has been created (more than 0 tables)
            if (count($entityManager->getConnection()->getSchemaManager()->listTables()) > 0) {
                $companyRepo = $entityManager->getRepository(Company::class);
                $company = $companyRepo->findOneBy(['name' => 'Guest']);
            } else {
                // Set temporary company for guest user
                $company = $this->tmpGuestCompany(); // Creates "new" Company, sets name, excludes in $entityManager from persisting/flushing it
            }
        }
    
        $listener->setCompany($company);
        $listener->setFilter(new CreatedByCompanyFilter($entityManager));
        $listener->setEntityManager($entityManager);
    
        return $listener;
    }
    

    }

    Lastly, the CreatedByCompanyFilter needs to actually do something, so it as below:

    class CreatedByCompanyFilter extends SQLFilter
    {
        public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
        {
            // Check if Entity implements CreatedByCompanyAwareInterface, if not, return empty string
            if (!$targetEntity->getReflectionClass()->implementsInterface(CreatedByCompanyInterface::class)) {
    
                return '';
            }
    
            if (!array_key_exists(CreatedByCompanyTrait::class, $targetEntity->getReflectionClass()->getTraits())) {
    
                throw new TraitNotImplementedException(
                    $targetEntity->getReflectionClass()->getName() . ' requires "' . CreatedByCompanyTrait::class . '" to be implemented.'
                );
            }
    
            $column = 'created_by_company';
            return "{$targetTableAlias}.{$column} = {$this->getParameter('company')}";
        }
    }
    

    Why this works

    The most important bit out of the above code is the following, based on Daredevel's tutorial linked earlier:

    public function onBootstrap(MvcEvent $event)
    {
        $this->getEntityManager()->getConfiguration()->addFilter('created-by-company', get_class($this->getFilter()));
        $filter = $this->getEntityManager()->getFilters()->enable('created-by-company');
    
        $filter->setParameter('company', $this->getCompany()->getId());
    }
    

    This is where we add the Filter to the configuration of the EntityManager on the first line. This allows us to use it. Required is that you give the Filter a name and you tell the EntityManager which class belongs to the name with the second param.

    Next, we enable() the Filter by name. Daredevel's tutorial showed me that the enable() function actually returns the filter just enabled.

    Using the returned Filter in the $filter variable, we can now use that instance to set parameters, which we do in the last statement.


    How is this different from what I tried in my question? Well, in my question I tried to do it the other way around. I tried both to enable the Filter via the config and via the Listener. However, with the latter method I tried creating and storing a Filter instance in a variable, setting the required parameters and then enabling it in the EntityManager, which is precisely the wrong way around as the enabling creates a new instance (and as such has no variables).

    Therefore I'm leaving this here for anyone that might stumble upon the same issue.