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:
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?
I was, of course, thinking wayyyyyy too complicated. I've now got it running using the following classes:
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.