Doctrine preUpdate event shows no changeset for associated collections - php

This is a question about the event system in Doctrine (within a Symfony project).
I have two classes, User and Visit, that are associated via a many-to-many relationship. I.e. a user can have many visits and a visit can have many users (that attend the visit).
class Visit
{
#[ORM\Column]
protected string $Date;
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: "Visits")]
#[ORM\JoinTable(name: "users_visits")]
protected Collection $Users;
public function __construct()
{
$this->Users = new ArrayCollection();
}
//... other properties and methods omitted
}
class User
{
#[ORM\Column]
protected string $Name;
#[ORM\ManyToMany(targetEntity: Visit::class, inversedBy: "Users")]
#[ORM\JoinTable(name: "users_visits")]
protected Collection $Visits;
public function __construct()
{
$this->Visits = new ArrayCollection();
}
//... other properties and methods omitted
}
I also have an UpdateSubscriber that is supposed to record certain inserts, updates or removals, in a separate sql-table to create an overview over all relevant changes later on.
class UpdateSubscriber implements EventSubscriberInterface
{
public function __construct(private LoggerInterface $logger)
{
}
public function getSubscribedEvents(): array
{
return [
Events::preUpdate,
Events::postPersist,
Events::postRemove,
Events::postFlush
];
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$this->logger->debug('Something has been updated');
if($args->hasChangedField('Date')){
$this->logger->debug('The date has been changed.');
}
if($args->hasChangedField('Users')){
$this->logger->debug('It was the Users field');
}
}
// ... other methods emitted
I have gotten this system to work, but when I run this test code
$visitRepo = $this->om->getRepository(Visit::class);
$userRepo = $this->om->getRepository(User::class);
// you can assume that visit 7 and user 8 already exist in the database
$v = $visitRepo->find(7);
$u = $userRepo->find(8);
$v->setDate('2022-01-05');
$this->om->flush();
$v->addUser($u);
$this->om->flush();
The test code works without errors and I can see a new row in the sql-table "users_visits". I can also see the date for visit 7 has been changed to 2022-01-05.
BUT: Checking the log I can only find
Something has been updated.
The date has been changed.
Something has been updated.
There is no "It was the Users field". Using my debugging tools I can see that the EntityChangeSet is empty during preUpdate() during the $v->addUser($u), which is weird and unexpected.
I have extensively been reading the docs for the event PreUpdate but there is no mentioning of why changes to associated collections are not shown in the EntityChangeSet or how I could track those changes in an EventSubscriber.
Do I have to go via the rather cumbersome UnitOfWork during the onFlushEvent? Or this there something I have been missing?

In case I myself or somebody else reads this, my pretty dirty solution was to collect all currently updated collections and then check if the property ("Users" in this case) matched on of these.
$updatedCollectionNames = array_map(
function (PersistentCollection $update) {
return $update->getMapping()['fieldName'];
},
$args->getEntityManager()->getUnitOfWork()->getScheduledCollectionUpdates()
);
// and later
if (in_array('Users', $updatedCollectionNames, true)){
$this->logger->debug('It was the Users field');
}
This seems dirty and contrived, but it will do for now until somebody has a better proposal.

Related

How to register a default login user?

**Context: ** I have 2 associated entities, being "persona" and "ingreso".
I tried to capture the logged in user and send it as a default variable in the form:
TextField::new('person','Person')
->formatValue(function ($value) {
return $value = $this->getUser();
})
->hideOnForm()
But: This arrives as a Null value in the database.
This is why I try to capture the user and save it from the entity, but I don't know a correct way to do it.
You are using ->hideOnForm, which remove the field in your form, so nothing is sent concerning person.
There is multiple way to do what you want, including similar answer such has having a hidden select with your user, however I do not consider it a good solution.
Have you considered using Event ?
In your case you could either listen using Doctrine events or with EasyAdmin Events.
Symfony events
<?php
namespace App\EventSubscriber;
use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent;
//... other imports
class EasyAdminSubscriber implements EventSubscriberInterface
{
private $tokenStorage
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage
}
public static function getSubscribedEvents()
{
return [BeforeEntityUpdatedEvent => ['beforeEntityUpdatedEvent'], ];
}
public function beforeEntityUpdatedEvent(BeforeEntityUpdatedEvent $event)
{
$entity = $event->getEntityInstance();
if ($entity instanceof YourEntityYouWantToListenTo) {
$entity->setPerson($this->tokenStorage->getToken()->getUser());
}
}

Symfony 3.4 - updating entity after soft-deleting related entity row

I've got an entity called Logs that has a ManyToOne relation to an HourlyRates entity. Both Logs and HourlyRates have date properties. When adding a log with a specific date, an hourlyRate is assigned to it if the log-date fits within the rate's time range. I'm using the Doctrine Extensions Bundle, so the data in each entity can be soft-deleted.
What needs to be done:
After soft-deleting an HourlyRate the related Log has to be updated, so that the nearest existing past HourlyRate takes the place of the deleted one.
I tried to use preSoftDelete, postSoftDelete, preRemove and postRemove methods inside an HourlyRate entity listener. The code was being executed and the setters were working properly, but the database hasn't been updated in any of said cases. An "EntityNotFoundException" was being thrown everytime.
My second approach was to use the preRemove event along with setting the cascade option to "all" by using annotations in the HourlyRate class. As a result, soft-deleting an hourlyRate caused soft-deleting of the related log.
The Log entity:
class Log
{
use SoftDeleteableEntity;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\HourlyRate", inversedBy="logs")
* #ORM\JoinColumn(nullable=false)
*/
private $hourlyRate;
public function setHourlyRate(?HourlyRate $hourlyRate): self
{
$this->hourlyRate = $hourlyRate;
return $this;
}
}
The HourlyRate entity:
class HourlyRate
{
use SoftDeleteableEntity;
//other code
/**
* #ORM\OneToMany(targetEntity="App\Entity\Log", mappedBy="hourlyRate", cascade={"all"})
*/
private $logs;
}
The HourlyRate entity listener:
class HourlyRateEntityListener
{
public function preRemove(HourlyRate $hourlyRate, LifecycleEventArgs $args)
{
$entityManager = $args->getObjectManager();
/** #var HourlyRateRepository $HRrepo */
$HRrepo = $entityManager->getRepository(HourlyRate::class);
foreach ($hourlyRate->getLogs() as $log)
{
$rate = $HRrepo->findHourlyRateByDate($log->getDate(), $log->getUser(), $hourlyRate);
$log->setHourlyRate($rate);
}
}
}
The repository method:
class HourlyRateRepository extends ServiceEntityRepository
{
public function findHourlyRateByDate(?\DateTimeInterface $datetime, User $user, ?HourlyRate $ignore = null): ?HourlyRate
{
$qb = $this->createQueryBuilder('hr')
->where('hr.date <= :hr_date')
->andWhere('hr.user = :user')
->orderBy('hr.date', 'DESC')
->setMaxResults(1)
->setParameters(array('hr_date' => $datetime, 'user' => $user));
//ignore the "deleted" hourlyRate
if($ignore){
$qb->andWhere('hr.id != :ignored')
->setParameter('ignored', $ignore->getId());
}
return $qb->getQuery()
->getOneOrNullResult()
;
}
}
Thank you in advance for any of your help.
EDIT:
Okay so after a whole week of trials and errors i finally managed to achieve the result I wanted.
I removed the One-To-Many relation between the hourlyRates and the logs from the entities, but left the $hourlyRate property inside the Log class. Then I got rid of the HourlyRateEntityListener and the preRemove() method from the LogEntityListener. Instead, I implemented the postLoad() method:
class LogEntityListener
{
public function postLoad(Log $log, LifeCycleEventArgs $args)
{
$entityManager = $args->getObjectManager();
$HRrepo = $entityManager->getRepository(HourlyRate::class);
/** #var HourlyRateRepository $HRrepo */
$rate = $HRrepo->findHourlyRateByDate($log->getDate(), $log->getUser());
$log->setHourlyRate($rate);
}
}
This approach allows me to set the proper hourlyRate for each log without involving the database. Idk if this solution is acceptable though.

How does DBAL read data that ORM inserts but has not yet “flush”?

For historical reasons, my pattern of running databases using Symfony is mixed. That is, the query uses DBAL and the insert uses ORM. Now you need to write a lot of data to the database. The flush in ORM can help me achieve business at the lowest cost.
All flush operations have been removed from the project. Put it in the __destruct of the controller.
However, doing so will cause DBAL to not find the latest changed data. Of course, these data ORMs can be obtained normally.
This is a very difficult problem. I hope to get guidance.
class BaseController extends Controller
{
public function __destruct()
{
$this->getDoctrine()->getManager()->flush();
}
public function indexAction()
{
$model = new CompanyModel();
$model->install(['company_name' => '1234']);
$model->update(['company_name' => 'abcd'], $model->lastInsertId);
}
}
class CompanyModel extends BaseController
{
public function validate($data, $id = false)
{
$this->entityManager = $this->getDoctrine()->getManager();
if(empty($id)){
$this->company_class = new Company();
}else{
if(!$this->is_exist($id)){
return false;
}
$this->company_class = $this->entityManager->getRepository(Company::class)->find($id);
}
if(array_key_exists('company_name', $data)){
$this->company_class->setCompanyName($data['company_name']);
}
if(self::$error->validate($this->company_class)){
return false;
}
return true;
}
public function insert($data)
{
if(!$this->validate($data)){
return false;
}
$this->company_class->setCreateAt(new \DateTime());
$this->entityManager->persist($this->company_class);
//$this->entityManager->flush();
$this->lastInsertId = $this->company_class->getId();
return true;
}
public function update($data, $id)
{
if(empty($id)){
self::$error->setError('param id is not null');
return false;
}
if(!$this->validate($data, $id)){
return false;
}
$this->company_class->setUpdateAt(new \DateTime());
//$this->entityManager->flush();
return true;
}
public function is_exist($id)
{
return $this->get('database_connection')->fetchColumn('...');
}
}
The final result of executing indexAction company_name is 1234; $ model-> update() was not executed successfully. The reason is that the $this-> is_exist() method that took the DBAL query did not find the ORM insert but did not flush the message.
Unchanging conditions,run
$this->entityManager->getRepository(Company::class)->find($id);
Is successful。
The problem is not the entity manager or dbal, as far as I can tell, but the usage of an anti-pattern, which I would call ... entanglement. What you should strive for is separation of concerns. Essentially: Your "CompanyModel" is an insufficient and bad wrapper for the EntityManager and/or EntityRepository.
No object should know about the entity manager. It should only be concerned with holding the data.
The entity manager should be concerned with persistence and ensuring integrity.
The controller is meant to orchestrate one "action", that can be adding one company, editing one company, batch-importing/updatig many companies.
Services can be implemented, when actions become to business-logic-heavy or when functionality is repeated.
(Note: the following code samples could be made way more elegant with using all the features that symfony provide, like ParamConverters, the Form component, the Validation component, I usually wouldn't write code this way, but I assume everything else would go way over your head - no offence.)
handling actions in the controller
controller actions (or service actions, really) are when you look at your problem from the task perspective. Like "I want to update that object with this data"). That's when you fetch/create that object, then give it the data.
use Doctrine\ORM\EntityManagerInterface;
class BaseController extends Controller {
public function __construct(EntityManagerInterface $em) {
$this->em = $em;
}
public function addAction() {
$company = new Company(['name' => '1234']); // initial setting in constructor
$this->em->persist($company);
// since you have the object, you can do any changes to it.
// just change the object
$company->update(['name' => 'abcd']); // <-- don't need id
// updates will be flushed as well!
$this->em->flush();
}
public function editAction($id, $newData) {
$company = $this->em->find(Company::class, $id);
if(!$company) {
throw $this->createNotFoundException();
}
$company->update($newData);
$this->em->flush();
}
// $companiesData should be an array of arrays, each containing
// a company with an id for update, or without an id for creation
public function batchAction(array $companiesData) {
foreach($companies as $companyData) {
if($companyData['id']) {
// has id -> update existing company
$company = $this->em->find(Company::class, $companyData['id']);
//// optional:
// if(!$company) { // id was given, but company does not exist
// continue; // skip
// // OR
// $company = new Company($companyData); // create
// // OR
// throw new \Exception('company not found: '.$companyData['id']);
// }
$company->update($companyData);
} else {
// no id -> create new company
$company = new Company($companyData);
$this->em->persist($company);
}
}
$this->em->flush(); // one flush.
}
}
the base controller should handle creating objects, and persisting it, so very basic business logic. some would argue, that some of those operations should be done in an adapted Repository for that class, or should be encapsulated in a Service. And they would be right, generally.
the entity handles it's internal state
Now, the Company class handles its own properties and tries to stay consistent. You just have to make some assumptions here. First of all: the object itself shouldn't care if it exists in the database or not. it's not its purpose! it should handle itself. Separation of concerns! The functions inside the Company entity should concern simple business logic, that concerns its INNER state. It doesn't need the database, and it should not have any reference to the database, it only cares about it's fields.
class Company {
/**
* all the database fields as public $fieldname;
*/
// ...
/**
* constructor for the inital state. You should never want
* an inconsistent state!
*/
public function __construct(array $data=[]) {
$this->validate($data); // set values
if(empty($this->createAt)) {
$this->createAt = new \DateTime();
}
}
/**
* update the data
*/
public function update(array $data) {
$this->validate($data); // set new values
$this->updateAt = new \DateTime();
}
public function validate(array $data) {
// this is simplified, but you can also validate
// here and throw exceptions and stuff
foreach($array as $key => $value) {
$this->$key = $value;
}
}
}
some notes
Now, there should be NO use case, where you get an object to persist and at the same time an update - with an id - that refers to the new object ... unless that object was given the id beforehand! HOWEVER. If you persist an object, that has an ID and you call $this->em->find(Company::class, $id) you would get that object back.
if you have many relations, there are always good ways to solve this problem without destroying separation of concerns! you should never inject an entity manager into an entity. the entity should not manage its own persistence! nor should it manage the persistence of linked objects. handling persistence is the purpose of the entity manager or entity repository. you should never need a wrapper around an object just to handle that object. be careful not to mix responsibilities of services, entities (objects) and controllers. In my example code, I have merged services and controllers, because in simple cases, it's good enough.

Symfony Restful API - Expose virtual property isLiked by current logged in user

There are two entities Restaurant and Users. Restaurant entity has many-to-many relation with user, field name favoriteBy.
<many-to-many field="favoriteBy" target-entity="UserBundle\Entity\Users" mapped-by="favoriteRestaurants"/>
I am using JMS Serializer along with FOSRestfulAPI. In restaurant listing API I have to expose one extra boolean field "isFavorited", which will be true if current logged in user has in array collection favoriteBy.
How I can find whether current user has favorited the restaurant or not within entity?
/**
* Get is favorited
* #JMS\VirtualProperty()
* #JMS\Groups({"listing", "details"})
*/
public function isFavorited()
{
// some logic in entity
return false;
}
One way I am thinking is to inject current user object to entity and user contains method to find out, but its look like not good approach.
Please suggest me some method, or guide me to right direction.
You could implments an EventSubscriberInterface as described here in the doc.
As Example:
use JMS\Serializer\EventDispatcher\EventSubscriberInterface;
use JMS\Serializer\EventDispatcher\ObjectEvent;
...
class RestaurantSerializerSubscriber implements EventSubscriberInterface
{
protected $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
public static function getSubscribedEvents()
{
return [
[
'event' => 'serializer.post_serialize',
'class' => Restaurant::class,
'method' => 'onPostSerialize',
],
];
}
public function onPostSerialize(ObjectEvent $event)
{
$visitor = $event->getVisitor();
$restaurant = $event->getObject();
// your custom logic
$isFavourite = $this->getCurrentUser()->isFavourite($restaurant);
$visitor->addData('isFavorited', $isFavourite);
}
/**
* Return the logged user.
*
* #return User
*/
protected function getCurrentUser()
{
return $this->tokenStorage->getToken()->getUser();
}
And register, as YML example:
acme.restaurant_serializer_subscriber:
class: Acme\DemoBundle\Subscriber\RestaurantSerializerSubscriber
arguments: ["#security.token_storage"]
tags:
- { name: "jms_serializer.event_subscriber" }
Hope this help
PS: You could also intercept the serialization group selected, let me know if you neet that code.
Entity should know nothing about current logged in user so injecting user into entity is not a good idea.
Solution 1:
This can be done with custom serialization:
// serialize single entity or collection
$data = $this->serializer->serialize($restaurant);
// extra logic
$data['is_favourited'] = // logic to check if it's favourited by current user
// return serialized data
Solution 2
This can be also achieved by adding Doctrine2->postLoad listener or subscriber after loading Restaurant entity. You can add dependency for current authenticated token to such listener and set there Restaurant->is_favorited virtual property that will be next serialized with JMS.

Doctrine Module: entity relationships being hydrated but not sticking to entity

I have a many-to-many relationship between users (the owning side) and user groups, and am having issues using doctrine module's hydrator to create a new user group.
When I create a new user group and hydrate, persist, and flush it, the records change in the database, but the entity variable itself representing the user group doesn't end up with any users in it post-hydration.
Context: We have a REST controller route that we use to create a new user group via POST. It accepts parameters to initialize it with some users via hydration. This operation successfully updates the database, but its response is incorrect. It is supposed to extract the data from the now-persistent entity and echo it back to the client. However, it fails to extract any users, so the response incorrectly returns as an empty group. Not using the hydrator's extract method and instead using more basic doctrine commands fails too--it seems like the entity variable itself is just not kept up to date after being persisted.
So my question really is: why is the hydrator not extracting users? If we've messed up the owner/inverse assocation, why is it working at all (i.e. persisting the users to the database but not to the entity).
Here is the relevant code, probably only the first two blocks are needed.
public function create($data) {
...
$hydrator = $this->getHydrator();
$em = $this->getEntityManager();
$entity = $this->getEntity();
$entity = $hydrator->hydrate($data, $entity);
// Persist the newly created entity
$em->persist($entity);
// Flush the changes to the database
$em->flush();
return $this->createResponse(
201,
true,
'Created item',
$this->getHydrator()->extract($entity)
);
Here is are the setters and getters the hydrator is using:
... more fields...
/**
* #ORM\ManyToMany(targetEntity="User", mappedBy="groups")
*/
protected $users;
...
/**
* Getter for users
*
* #return mixed
*/
public function getUsers() {
return $this->users;
}
public function addUsers(Collection $users) {
foreach ($users as $user) {
$user->addGroups(new ArrayCollection(array($this)));
}
}
I think the above is the only relevant code, but I'll include some more in case I'm wrong. Here is the getHydrator method:
public function getHydrator() {
if(null === $this->hydrator) {
$hydrator = new DoctrineObject($this->getEntityManager(), $this->getEntityName());
// Use Entity metadata to add extraction stategies for associated fields
$metadata = $this->em->getClassMetadata($this->getEntityName());
foreach ($metadata->associationMappings as $field => $mapping) {
// Use our custom extraction strategies for single and collection valued associations
if($metadata->isSingleValuedAssociation($field)) {
$hydrator->addStrategy($field, new RestfulExtractionStrategy());
}
else if($metadata->isCollectionValuedAssociation($field)) {
$hydrator->addStrategy($field, new RestfulExtractionCollectionStrategy());
}
}
$this->hydrator = $hydrator;
}
return $this->hydrator;
}
Here is the RestfulExtractionCollectionStrategy (the other strategy isn't being used here, I have verified this).
namespace Puma\Controller;
use DoctrineModule\Stdlib\Hydrator\Strategy\AllowRemoveByValue;
use Doctrine\Common\Collections\Collection;
/**
* You can use this strategy with fields that are collections,
* e.g. one to many. You need to use the RestfulExtractionStrategy
* if you want to get extract entities from a singleton field, e.g. manyToOne.
**/
class RestfulExtractionCollectionStrategy extends AllowRemoveByValue
{
public function extract($value)
{
if ($value instanceof Collection) {
$return = array();
foreach ($value as $entity) {
if(method_exists($entity, 'getId')){
$return[] = $entity->getId();
}
else {
$return[] = $entity;
}
}
return $return;
}
return $value;
}
}
I am not quite familiar with hydration, etc., so your code looks kind of strange to me and I cannot guarantee this will work, but have you tried to refresh the entity after flushing (i.e. $em->refresh($entity)) and maybe return the entity instead of $this->getHydrator()->extract($entity)?
I think I've finally solved it--I added a line to the "setter" method, addUsers which manually updates the users property of the group after updating the related users. I would be a bit surprised if this was best practice, though. I had thought that updating the owning side (the users) would automatically update the inverse side (the user group). Perhaps I was wrong. If anyone else has a better idea I'll gladly give the answer credit to them.
public function addUsers(Collection $users) {
foreach ($users as $user) {
$user->addGroups(new ArrayCollection(array($this)));
// This is the new line (updating the usergroup property manually)
$this->users->add($user);
}
}

Categories