Symfony WebTestCase hook into database listeners - php

So Im trying to hook into the database events of doctrine from the WebTestCase of Symfony.
I setup the client like this:
self::$client = static::createClient();
self::$client->followRedirects(true);
self::$client->setMaxRedirects(10);
$doctrineDefaultManager = self::getContainer()->get('doctrine.orm.default_entity_manager');
$doctrineDefaultManager->getConnection()->getConfiguration()->getSQLLogger();
/** some other code loading database and fixtures etc **/
// Im adding the event listener to the defaultmanager here
$doctrineDefaultManager->getEventManager()->addEventListener([Events::onFlush], new FlushExampleListener());
https://www.doctrine-project.org/projects/doctrine-orm/en/2.13/reference/events.html#listening-and-subscribing-to-lifecycle-events
Then I have the example class to: (this class never get logged or fired or dumped)
use Doctrine\ORM\Event\OnFlushEventArgs;
class FlushExampleListener
{
/**
* #param OnFlushEventArgs $eventArgs
* #return void
*/
public function onFlush(OnFlushEventArgs $eventArgs): void
{
$em = $eventArgs->getObjectManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
dd($entity);
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
dd($entity);
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
dd($entity);
}
foreach ($uow->getScheduledCollectionDeletions() as $col) {
dd($col);
}
foreach ($uow->getScheduledCollectionUpdates() as $col) {
dd($col);
}
}
}
https://www.doctrine-project.org/projects/doctrine-orm/en/2.13/reference/events.html#reference-events-on-flush
Then I post a form with the WebTestCase like this:
public function iSubmitAFormOnPageValue(string $url, string $btn): void
{
$crawler = self::$client->request('GET', $this->getHost() . $url, [], [], []);
$form = $crawler->selectButton($btn)->form();
$this->crawler = self::$client->submit($form, $this->formData);
$this->response = self::$client->getResponse();
}
What works fine and in this case if I hit the /entity/new route it works. I see a new record in the database. Also can I see with a new GET request that indeed the webpage is updated. Only does the onFlush event not get fired? So how can I see what get inserted into the database from the WebCaseTest or does symfony use another doctrine manager to insert stuff into the database where I need to put the event listener on?

Related

Tabular Data Processing in Symfony API Platform (Multiple instances of same entity type)

I have this business requirement where I need to create update entities of the same entity type with a single request(Tabular form).
Entity Class:
class Tag{
private $name;
}
I have setup this custom controller to accept multiple instances of the Tag class in json.
For example :
{
[
{"name":"tag 1","id":"T1"},
{"name":"tag 2","id":"T2"},
{"name":"tag 3","id":"T3"}
]
}
And my custom action is defined as below.
class TagMultipleAction extends AbstractController
{
public function __construct(
private EntityManagerInterface $entityManager,
private SerializerInterface $serializer,
private ValidatorInterface $validator
)
{
}
public function __invoke(TagData $data): JsonResponse
{
$tagPostErrors = [];
foreach ($data as $tagPst) {
$tag = new Tag();
$tag->setName($tagPst['name']);
try {
$this->validator->validate($tag, ['groups' => 'create']);
$this->entityManager->persist($tag);
} catch (ValidationException $ex) {
$tagPostErrors[$tagPst['id']] = [$ex->getMessage()];
}
}
if (count($tagPostErrors) > 0) {
return new JsonResponse($tagPostErrors, 400);
}
$this->entityManager->flush();
return new JsonResponse(null, 204);
}
This controller works fine and all the data persists without any issue.
What I want to know is that instead of me mapping the post data to object($tag->setName($tagPst['name']), is there a way to automate this using DTO's. Hoping to find a better way to deal with tabular data.

ZF2 - How to Listen for Events and Trigger a Service because of it?

Have been trying to learn how to implement Services because they get Triggered by a Listener. Have been doing a serious lot of reading the last few days to get it to work, but have been finding it difficult. Thus I'm thinking my understanding of the order of things might be flawed.
The use case I'm trying to get to work is the following:
Just before an Address Entity (with Doctrine, but that's not
important) gets saved (flushed), a Service must be triggered to check
if the Coordinates for the Address are set, and if not, create and
fill a new Coordinates Entity and link it to the Address. The
Coordinates are to be gotten from Google Maps Geocoding API.
Will show below what and how I'm understanding things in the hope I make myself clear. Will do it in steps to show added code in between and tell you what does and doesn't work, as far as I know.
Now, my understanding of all of the information I've gotten the last few days is this:
A Listener has to be registered with ZF2's ServiceManager. The listener "attaches" certain conditions to the (Shared)EventManager. An EventManager is unique to an object, but the SharedEventManager is 'global' in the application.
In the Address module's Module.php class I've added the following function:
/**
* #param EventInterface $e
*/
public function onBootstrap(EventInterface $e)
{
$eventManager = $e->getTarget()->getEventManager();
$eventManager->attach(new AddressListener());
}
This gets works, the AddressListener gets triggered.
The AddressListener is as follows:
use Address\Entity\Address;
use Address\Service\GoogleCoordinatesService;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
use Zend\Stdlib\CallbackHandler;
class AddressListener implements ListenerAggregateInterface
{
/**
* #var CallbackHandler
*/
protected $listeners;
/**
* #param EventManagerInterface $events
*/
public function attach(EventManagerInterface $events)
{
$sharedEvents = $events->getSharedManager();
// Not sure how and what order params should be. The ListenerAggregateInterface docblocks didn't help me a lot with that either, as did the official ZF2 docs. So, been trying a few things...
$this->listeners[] = $sharedEvents->attach(GoogleCoordinatesService::class, 'getCoordinates', [$this, 'addressCreated'], 100);
$this->listeners[] = $sharedEvents->attach(Address::class, 'entity.preFlush', [GoogleCoordinatesService::class, 'getCoordinates'], 100);
}
/**
* #param EventManagerInterface $events
*/
public function detach(EventManagerInterface $events)
{
foreach ($this->listeners as $index => $listener) {
if ($events->detach($listener)) {
unset($this->listeners[$index]);
}
}
}
public function addressCreated()
{
$foo = 'bar'; // This line is here to as debug break. Line is never used...
}
}
I was expecting a Listener to work as a sort-of stepping stone point to where things get triggered, based on the ->attach() functions in the function attach(...){}. However, this does not seem to work, as nothing gets triggered. Not the addressCreated() function and not the getCoordinates function in the GoogleCoordinatesService.
The code above is supposed to trigger the GoogleCoordinatesService function getCoordinates. The Service has a few requirements though, such as the presence of the EntityManager of Doctrine, the Address Entity it concerns and configuration.
To that effect, I've created the following configuration.
File google.config.php (gets loaded, checked that)
return [
'google' => [
'services' => [
'maps' => [
'services' => [
'geocoding' => [
'api_url' => 'https://maps.googleapis.com/maps/api/geocode/json?',
'api_key' => '',
'url_params' => [
'required' => [
'address',
],
'optional' => [
'key'
],
],
],
],
],
],
],
];
And in module.config.php I've registered the Service with a Factory
'service_manager' => [
'factories' => [
GoogleCoordinatesService::class => GoogleCoordinatesServiceFactory::class,
],
],
The Factory is pretty standard ZF2 stuff, but to paint a complete picture, here is the GoogleCoordinatesServiceFactory.php class. (Removed comments/typehints/etc)
class GoogleCoordinatesServiceFactory implements FactoryInterface
{
public function createService(ServiceLocatorInterface $serviceLocator, $options = [])
{
$serviceManager = $serviceLocator->getServiceLocator();
$entityManager = $serviceManager->get(EntityManager::class);
$config = $serviceManager->get('Config');
if (isset($options) && isset($options['address'])) {
$address = $options['address'];
} else {
throw new InvalidArgumentException('Must provide an Address Entity.');
}
return new GoogleCoordinatesService(
$entityManager,
$config,
$address
);
}
}
Below is the GoogleCoordinatesService class. However, nothing ever gets triggered to executed in there. As it doesn't even gets called I'm sure the problem lies in the code above, but cannot find out why. From what I've read and tried, I'm expecting that the class itself should get called, via the Factory and the getCoordinates function should be triggered.
So, the class. I've removed a bunch of standard getters/setters, comments, docblocks and typehints to make it shorter.
class GoogleCoordinatesService implements EventManagerAwareInterface
{
protected $eventManager;
protected $entityManager;
protected $config;
protected $address;
/**
* GoogleCoordinatesServices constructor.
* #param EntityManager $entityManager
* #param Config|array $config
* #param Address $address
* #throws InvalidParamNameException
*/
public function __construct(EntityManager $entityManager, $config, Address $address)
{
$this->config = $config;
$this->address = $address;
$this->entityManager = $entityManager;
}
public function getCoordinates()
{
$url = $this->getConfig()['api_url'] . 'address=' . $this->urlFormatAddress($this->getAddress());
$response = json_decode(file_get_contents($url), true);
if ($response['status'] == 'OK') {
$coordinates = new Coordinates();
$coordinates
->setLatitude($response['results'][0]['geometry']['location']['lat'])
->setLongitude($response['results'][0]['geometry']['location']['lng']);
$this->getEntityManager()->persist($coordinates);
$this->getAddress()->setCoordinates($coordinates);
$this->getEntityManager()->persist($this->getAddress());
$this->getEntityManager()->flush();
$this->getEventManager()->trigger(
'addressReceivedCoordinates',
null,
['address' => $this->getAddress()]
);
} else {
// TODO throw/set error/status
}
}
public function urlFormatAddress(Address $address)
{
$string = // format the address into a string
return urlencode($string);
}
public function getEventManager()
{
if ($this->eventManager === null) {
$this->setEventManager(new EventManager());
}
return $this->eventManager;
}
public function setEventManager(EventManagerInterface $eventManager)
{
$eventManager->addIdentifiers([
__CLASS__,
get_called_class()
]);
$this->eventManager = $eventManager;
return $this;
}
// Getters/Setters for EntityManager, Config and Address
}
So, that's the setup to handle it when a certain event gets triggered. Now it should, of course, get triggered. For this use case I've setup a trigger in the AbstractActionController of my own (extends ZF2's AbstractActionController). Doing that like so:
if ($form->isValid()) {
$entity = $form->getObject();
$this->getEntityManager()->persist($entity);
try {
// Trigger preFlush event, pass along Entity. Other Listeners can subscribe to this name.
$this->getEventManager()->trigger(
'entity.preFlush',
null,
[get_class($entity) => $entity] // key = "Address\Entity\Address" for use case
);
$this->getEntityManager()->flush();
} catch (\Exception $e) {
// Error thrown
}
// Success stuff, like a trigger "entity.postFlush"
}
So yea. At the moment at a bit of a loss on how to get it working.
Any help would be very much appreciated and would love explanations as to the "why" of it is that a solution works. That would really help me out making more of these services :)
Been at it for a while, but have managed to figure out why it was not working. I was attaching Listeners to EventManagers, but should have been attaching them to the SharedEventManager. This is because I have the triggers (in this instance) in the AbstractActionController, thus they all create their own EventManager (as they're unique) when instantiated.
Has been a tough few days wrapping my head around it all, but this article helped me out most, or perhaps it just made things click with my original research in the question and subsequent trial & error + debugging.
Below the code as it is now, in working order. I'll try to explain along as the code comes as to how I understand that it works. If I get it wrong at some point I hope someone corrects me.
First up, we need a Listener, a class which registers components and events to "listen" for them to trigger. (They listen for certain (named) objects to trigger certain events)
The realization quickly came that pretty much every Listener would need the $listeners = []; and the detach(EventManagerInterface $events){...} function. So I created an AbstractListener class.
namespace Mvc\Listener;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
/**
* Class AbstractListener
* #package Mvc\Listener
*/
abstract class AbstractListener implements ListenerAggregateInterface
{
/**
* #var array
*/
protected $listeners = [];
/**
* #param EventManagerInterface $events
*/
public function detach(EventManagerInterface $events)
{
foreach ($this->listeners as $index => $listener) {
if ($events->detach($listener)) {
unset($this->listeners[$index]);
}
}
}
}
After the above mentioned realization about having to use the SharedEventManager and with the AbstractListener created, the AddressListener class has ended up like so.
namespace Address\Listener;
use Address\Event\AddressEvent;
use Admin\Address\Controller\AddressController;
use Mvc\Listener\AbstractListener;
use Zend\EventManager\EventManagerInterface;
/**
* Class AddressListener
* #package Address\Listener
*/
class AddressListener extends AbstractListener
{
/**
* #param EventManagerInterface $events
*/
public function attach(EventManagerInterface $events)
{
$sharedManager = $events->getSharedManager();
$sharedManager->attach(AddressController::class, 'entity.postPersist', [new AddressEvent(), 'addCoordinatesToAddress']);
}
}
The main difference with attaching events to EventManager versus the SharedEventManager is that the latter listens for a specific class to emit a trigger. In this instance it will listen for the AddressController::class to emit the trigger entity.postPersist. Upon "hearing" that it's triggered it will call a callback function. In this case that is registered with this array parameter: [new AddressEvent(), 'addCoordinatesToAddress'], meaning that it will use the class AddressEvent and the function addCoordinatesToAddress.
To test if this works, and if you're working along with this answer, you can create the trigger in your own Controller. I've been working in the addAction of the AbstractActionController, which gets called by the addAction of the AddressController. Below the trigger for the Listener above:
if ($form->isValid()) {
$entity = $form->getObject();
$this->getEntityManager()->persist($entity);
$this->getEventManager()->trigger(
'entity.postPersist',
$this,
[get_class($entity) => $entity]
);
try {
$this->getEntityManager()->flush();
} catch (\Exception $e) {
// Error stuff
}
// Remainder of function
}
The ->trigger() function in the above code shows the usage of the following parameters:
'entity.postPersist' - This is the event name
$this - This is the "component" or object the event is called for. In this instance it will be Address\Controller\AddressController
[get_class($entity) => $entity] - These are parameters to send along with this Event object. It will cause you to have available $event->getParams()[Address::class] which will have the $entity value.
The first two parameters will trigger the Listener in the SharedEventManager. To test if it all works, it's possible to modify the Listener's attach function.
Modify it to this and create a function within the the Listener so you can see it working:
public function attach(EventManagerInterface $events)
{
$sharedManager = $events->getSharedManager();
$sharedManager->attach(AddressController::class, 'entity.postPersist', [$this, 'test']);
}
public function test(Event $event)
{
var_dump($event);
exit;
}
Lastly, to make sure that the above actually works, the Listener must be registered with the EventManager. This happens in the onBootstrap function in the Module.php file of the module (Address in this case). Register like below.
public function onBootstrap(MvcEvent $e)
{
$eventManager = $e->getApplication()->getEventManager();
$eventManager->attach(new AddressListener());
}
If you debug the code of the addAction in the AbstractActionController, see it pass the trigger and next you're in the test function, then your Listener works.
The above code also implies that the AddressListener class can be used to attach more than one listener. So you could also register stuff for entity.prePersist, entity.preFlush, entity.postFlush and anything else you can think of.
Next up, revert the Listener back to what it was at the beginning (revert the attach function and remove the test function).
I also noticed that pretty much every Event handling class would need to be able to set and get the EventManager. Thus, for this I've created an AbstractEvent class, like below.
namespace Mvc\Event;
use Zend\EventManager\EventManager;
use Zend\EventManager\EventManagerAwareInterface;
use Zend\EventManager\EventManagerInterface;
abstract class AbstractEvent implements EventManagerAwareInterface
{
/**
* #var EventManagerInterface
*/
protected $events;
/**
* #param EventManagerInterface $events
*/
public function setEventManager(EventManagerInterface $events)
{
$events->setIdentifiers([
__CLASS__,
get_class($this)
]);
$this->events = $events;
}
/**
* #return EventManagerInterface
*/
public function getEventManager()
{
if (!$this->events) {
$this->setEventManager(new EventManager());
}
return $this->events;
}
}
To be honest, I'm not quite sure why we set 2 identifiers in the setEventManager function. But suffice to say that it's used to register callbacks for Events. (this could use more/detailed explanation if someone feels so inclined as to provide it)
In the AddressListener we're trying to call the addCoordinatesToAddress function of the AddressEvent class. So we're going to have to create that, I did it like below.
namespace Address\Event;
use Address\Entity\Address;
use Address\Service\GoogleGeocodingService;
use Country\Entity\Coordinates;
use Mvc\Event\AbstractEvent;
use Zend\EventManager\Event;
use Zend\EventManager\Exception\InvalidArgumentException;
class AddressEvent extends AbstractEvent
{
public function addCoordinatesToAddress(Event $event)
{
$params = $event->getParams();
if (!isset($params[Address::class]) || !$params[Address::class] instanceof Address) {
throw new InvalidArgumentException(__CLASS__ . ' was expecting param with key ' . Address::class . ' and value instance of same Entity.');
}
/** #var Address $address */
$address = $params[Address::class];
if (!$address->getCoordinates() instanceof Coordinates) {
/** #var GoogleGeocodingService $geocodingService */
$geocodingService = $event->getTarget()->getEvent()->getApplication()->getServiceManager()->get(GoogleGeocodingService::class);
$geocodingService->addCoordinatesToAddress($address);
}
$params = compact('address');
$this->getEventManager()->trigger(__FUNCTION__, $this, $params);
}
}
In the above you can see that first we check if the parameter we expect has been passed along with the Event $event parameter. We know what we should expect and what name the key should have, so we check explicitly.
Next we check if the received Address Entity object already has a Coordinates object associated with it, if it doesn't, we call a Service to make it happen.
After the if() statement has run, we fire another trigger. We pass along this Event object and the parameters. This last step is not required, but can be handy if you wish to chain events.
In the question I mentioned a use case. The above code enables the Service (GoogleGeocodingService) to get passed the it's requirements and combined with the configuration for the Factory, it gets created via Zend Magic with the ServiceManager.
The code to add a new Coordinates object to the existing Address object was not modified, so I won't make it part of the answer, you can find that in the question.

how to synchronize a value between two entities

My project is a php project built with symfony 2.7
I have two entities: Lead and Customer.
Some user action create a Lead entity, and an admin can create a Customer entity based on a Lead.
Both the Lead and the Customer entity have a one to many relation to an entity Status (a Lead/Customer can have a status)
Up until now the Status of a Lead was independent from that of the Customer created from it (and vice versa), but now, I need to keep them synchronized.
They have to be the same.
I don't want to do that in controllers, because the status can be changed in many ways, and it's likely new ways will be added.
So I tried to do it with doctrine PreUpdate event listeners, but for some reason that's just not working (changing the other entity in the first one's PreUpdate event triggers a PreUpdate on the second one - and that probably kills it. but I'm not sure).
I'm going to keep trying fixing the PreUpdate event listeners, unless someone here has a better idea?
Thanks :-)
Customer PreUpdate listener
use CRM\CoreBundle\Entity\Customer;
use Doctrine\ORM\Event\PreUpdateEventArgs;
class CustomerPreUpdateListener
{
public function preUpdate(PreUpdateEventArgs $args)
{
$entity = $args->getObject();
if (!($entity instanceof Customer)) {
return;
}
if($this->isStatusChanged($args)) {
$this->syncParentLeadStatus($args);
}
}
private function isStatusChanged(PreUpdateEventArgs $args)
{
return $args->hasChangedField('status');
}
private function syncParentLeadStatus(PreUpdateEventArgs $args)
{
/** #var Customer $entity */
$entity = $args->getObject();
if(!$entity->getLead()) {
return;
}
$newStatus = $args->getNewValue('status');
$entity->getLead()->setStatus($newStatus);
$args->getEntityManager()->persist($entity->getLead());
}
}
Lead PreUpdate listener
use CRM\BasicLeadsBundle\Entity\Lead;
use CRM\CoreBundle\Entity\Customer;
use Doctrine\ORM\Event\PreUpdateEventArgs;
class LeadPreUpdateListener
{
public function preUpdate(PreUpdateEventArgs $args)
{
$entity = $args->getObject();
if (!($entity instanceof Lead)) {
return;
}
if($this->isStatusChanged($args)) {
$this->syncCustomerStatus($args);
}
}
private function isStatusChanged(PreUpdateEventArgs $args)
{
return $args->hasChangedField('status');
}
private function syncCustomerStatus(PreUpdateEventArgs $args)
{
/** #var Lead $entity */
$entity = $args->getObject();
if(0 === $entity->getCustomersCreated()->count()) {
return;
}
$newStatus = $args->getNewValue('status');
foreach ($entity->getCustomersCreated() as $customer) {
/** #var Customer $customer */
$customer->setStatus($newStatus);
$args->getEntityManager()->persist($customer);
}
}
}

Doctrine preUpdate does not actually change entity

I am using Doctrine 2.4.6 in my Symfony 2.6.1 project. The problem is that changes made to entity in preUpdate callback are not saved in database. Code follows:
class MyListener {
public function preUpdate(PreUpdateEventArgs $args) {
$entity = $args->getEntity();
$args->setNewValue('name', 'test');
// echo $args->getNewValue('name'); --> prints 'test'
}
}
class DefaultController extends Controller {
/**
* #Route("/commit", name="commit")
*/
public function commitAction(Request $request) {
$content = $request->getContent();
$serializer = $this->get('jms_serializer');
/* #var $serializer \JMS\Serializer\Serializer */
$em = $this->getDoctrine()->getManager();
/* #var $em \Doctrine\Orm\EntityManagerInterface */
$persons = $serializer->deserialize($content, 'ArrayCollection<AppBundle\Entity\Person>', 'json');
/* #var $persons \AppBundle\Entity\Person[] */
foreach($persons as $person) {
$em->merge($person);
}
$em->flush();
return new JsonResponse($serializer->serialize($persons, 'json'));
// Person name is NOT 'test' here.
}
}
The preUpdate doesn't allow you to make changes to your entities. You can only use the computed change-set passed to the event to modify primitive field values. I bet if you check the database you'll see that the Person entities did get updated, you just won't see them in the $persons variable until the next time you manually retrieve them.
What you'll have to do after the flush is retrieve the entities from the database to see their updates values:
$em->flush();
$personIds = array_map(function($person) { return $person->getId(); }, $persons);
$updatedPersons = $em->getRepository('AppBundle:Person')->findById($personIds);
return new JsonResponse($serializer->serialize($updatedPersons, 'json'));

Doctrine's hasChangedField to account for changes made in the listener

I have a User entity, and I would like to archive it when banned. I have the following preUpdate listener:
/**
* #ORM\PreUpdate
*/
public function preUpdate(PreUpdateEventArgs $eventArgs) {
if ($eventArgs->hasChangedField('banned') {
$this->setIsArchived(true);
}
if ($eventArgs->hasChangedField('isArchived')) {
/* do Special work here */
}
}
How do I inform eventArgs about the field changed inside the handler itself?
if you edit an entity within the eventArgs, I think you need to persist it and then run the computeChangeSet or computeChangeSets in the UnitOfWork in order for you to use the hasChangedField:
$entity = $eventArgs->getObject();
$em = $eventArgs->getObjectManager();
$uow = $em->getUnitOfWork();
if ($eventArgs->hasChangedField('banned') {
$entity->setIsArchived(true);
$em->persist($entity);
}
$uow->computeChangeSets();
if ($eventArgs->hasChangedField('isArchived')) {
/* do Special work here */
}

Categories