How to remove a parent entity if a child collection is empty? - symfony

I have 2 entities: Game and Product. A Game has a One to Many relationship with Product.
Game is defined as
class Game
{
/**
* #ORM\OneToMany(targetEntity=Product::class, mappedBy="game")
*/
private $products;
}
In order to delete Game when $games->getProducts() is empty, I created an onFlush listener. The issue is that the count of products is the count before the changes are actually applied. This will work fine if I remove entities one by one, but will fail if I remove 2 or more products at once as I cannot confirm that collection is empty, any idea how I could achieve this?
The Listener is defined as such:
class ChainDeletionListener {
public function onFlush(OnFlushEventArgs $event): void {
$em = $event->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityDeletions() as $entity) {
// We check if the deleted entities are Product
if ($entity instanceof Product) {
/** #var Product $entity */
$game = $entity->getGame();
if (sizeof($game->getProducts()) === 1) {
// If the Product we are removing is the last one, we also remove the Game
$em->remove($game);
$uow->computeChangeSet($em->getClassMetadata(get_class($entity)), $entity);
}
}
}
}
}
Thank you for the help!

In your #OneToMany annotation, add "cascade" attribute:
class Game
{
/**
* #ORM\OneToMany(targetEntity=Product::class, mappedBy="game", cascade={"persist", "remove"})
*/
private $products;
}

The simple approach would be to actually load the game entity with its products collection and remove the products in that collection alongside.
class ChainDeletionListener {
public function onFlush(OnFlushEventArgs $event): void {
$em = $event->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityDeletions() as $entity) {
// We check if the deleted entities are Product
if ($entity instanceof Product) {
/** #var Product $entity */
$game = $entity->getGame();
$game->removeProduct($entity); // <-- NEW, might have to implement
if (sizeof($game->getProducts()) === 0) { // <-- NEW changed size: 0
// If the Product we are removing is the last one, we also remove the Game
$em->remove($game);
$uow->computeChangeSet($em->getClassMetadata(get_class($entity)), $entity);
}
}
}
}
}
This assumes a fault-tolerant removal Game::removeProduct(Product) implementation. The obvious downside is the games' collections always being loaded. I don't see a safe way around that.
Alternatively, you could go post-flush and check for games with zero products.

Related

get autogenerated id for additional field, using post-persist

i have a product with an autogenarete id and also have a productcode field, which grabs values based on user choices combined with the autogenated key to make the productcode. However i cannot grab the autogenate id when inserting a new product.
I used first prepersist & preupdate but that doesn't grab the id when inserting a new product. only when updating it grabs the id
/**
* #ORM\PrePersist
* #ORM\PreUpdate
*/
public function setProductcode()
{
$option1 = $this->option1;
$option2 = $this->option2;
$id = $this->id;
$whole = $option1.''.$option2.''.$id;
$this->productcode = $whole;
}
i try to use postpersist, and changed my field to be nullablae true but it saves the productcode as null.
/**
* #var string
*
* #ORM\Column(type="string", length=191, unique=true, nullable=true)
*/
private $productcode;
I used postload and postpersist together and it does show the productcode as output.. but it isn't save it the db.
* #ORM\PostLoad
* #ORM\PostPersist
How can i grab the id in the entity to put it in additional field? Thanks in advance!
edit
I made an easyadminsubcriber and it works when i use the pre_persist return.
However the code below is updated to post_persist. but i have trouble implementing the flush function together with lifecycleeventargs.
i got the following error back
Argument 2 passed to App\EventSubscriber\EasyAdminSubscriber::setProductcode() must be an instance of Doctrine\Common\Persistence\Event\LifecycleEventArgs, string given, called in
below is my post_persist code
<?php
# src/EventSubscriber/EasyAdminSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use App\Entity\Product;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
class EasyAdminSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return array(
'easy_admin.post_persist' => array('setProductcode'),
);
}
/**
* #param LifecycleEventArgs $args
*/
public function setProductcode(GenericEvent $event, LifecycleEventArgs $args)
{
$entityManager = $args->getEntityManager();
$entity = $event->getSubject();
if (!($entity instanceof Product)) {
return;
}
$whole = 'yooo';
$entityManager->flush();
$entity->setProductcode($whole);
$event['entity'] = $entity;
}
}
by default, the id is only set, when the entity is flushed to the database. this means, you have to generate your product code after you have flushed the entity and then flush again. doctrine can't use some fancy magic to determine the id before it actually hears back from the database, so there's not really another way. (if you want to do all of this in-entity, I can't imagine another practical and clean way to do this)
update
you should use PostPersist (while keeping PreUpdate).
The postPersist event occurs for an entity after the entity has been made persistent. It will be invoked after the database insert operations. Generated primary key values are available in the postPersist event. (source)
so, the generated primary key is available there. However, this is only after you flushed the entity. So, you'd have to flush again to write the productcode to the database as well.
create proper event handlers (because "setProductcode" is a setter, not an event handler, at least name-wise)
/**
* PostPersist triggers after the _creation_ of entities in db
* #ORM\PostPersist
*/
public function postPersist(LifecycleEventArgs $args) {
$this->setProductcode();
// need to flush, so that changes are written to database
$args->getObjectManager()->flush();
}
/**
* PreUpdate triggers before changes are written to db
* #ORM\PreUpdate
*/
public function preUpdate() {
$this->setProductcode();
// don't need to flush, this happens before the database calls
}
(see https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#lifecycle-callbacks-event-argument for further information)
(disclaimer: this answer was heavily edited since it first was created, leaving the connected comments partly without relevant references)
Do you really need to persist the productcode if it is just a concatenation of other columns? What about just using an efficient getter?
public function getProductcode()
{
if(!empty($this->productcode)){
return $this->productcode;
}
if(empty($this->id)){
return "to be determined";
}
$this->productcode = $this->option1 . $this->option2 . $this->id;
return $this->productcode;
}
Alright so i have now 2 solutions to set the autogenerate id in another field (by not using the controller). First one is directly in entity file itself as shown in #jakumi answer.
public function setProductcode()
{
$part = $this->producttype->gettypenumber();
$id1 = $this->id;
$part = sprintf("%03d", $id1);
$whole = $part1.''.$part2;
return $this->productcode= $whole;
}
/**
* PostPersist triggers after the _creation_ of entities in db
* #ORM\PostPersist
*/
public function postPersist(LifecycleEventArgs $args) {
$this->setPoductcode();
// need to flush, so that changes are written to database
$args->getObjectManager()->flush();
}
/**
* PreUpdate triggers before changes are written to db
* #ORM\PreUpdate
*/
public function preUpdate() {
$this->setProductcode();
// don't need to flush, this happens before the database calls
}
Another solutions is to use the eventsubscriber.
<?php
# src/EventSubscriber/EasyAdminSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Product;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
class EasyAdminSubscriber implements EventSubscriberInterface
{
public function __construct(EntityManagerInterface $em) {
$this->em = $em;
}
public static function getSubscribedEvents()
{
return array(
'easy_admin.post_persist' => array('setProductcode'),
);
}
public function setProductcode(GenericEvent $event)
{
$entity = $event->getSubject();
if (!($entity instanceof Product)) {
return;
}
$this->em->flush();
$entity->setProductcode();
$this->em->flush();
}
}
and my entity code with postpersist & preupdate
/**
* #ORM\PostPersist
* #ORM\PreUpdate
*/
public function setProductcode()
{
$part1 = $entity->getProducttype()->getTypenumber();
$id1 = $entity->getId();
$part2 = sprintf("%03d", $id1);
$whole = $part1.$part2;
$this->productcode = $whole;
}
Thanks #Jakumi for explanation & guidelines for both solutions.

Symfony: Create new entity inside doctrine EntityListener with ManyToOne association

I'm trying to record every change in quantity of a given item. For that purpose, I listen for a change of an Item entity and wish to create a new Transaction instance with details about the action. So I'm creating an entity inside a listener.
I've set up everything according to the documentation and created the listener based on this example.
The code (I believe) is relevant for my problem is following.
ItemListener
// ...
private $log;
/** #ORM\PreUpdate */
public function preUpdateHandler (Item $item, PreUpdateEventArgs $args)
{
$changeSet = $args->getEntityManager()->getUnitOfWork()->getEntityChangeSet($item)['quantity'];
$quantityChange = $changeSet[1] - $changeSet[0];
$transaction = new Transaction();
$transaction->setItem($item);
$transaction->setQuantityChange($quantityChange);
$this->log = $transaction;
}
/** #ORM\PostUpdate */
public function postUpdateHandler(Item $item, LifecycleEventArgs $args)
{
$em = $args->getEntityManager();
$em->persist($this->log);
$em->flush();
}
This works perfectly. However, the problem is when I add another field to the transaction entity. The user field inside Transaction entity has ManyToOne relation. Now when I try to set the user inside the preUpdateHandler, it leads to and undefined index error inside the UnitOfWork function of the Entity Manager.
Notice: Undefined index: 000000003495bf92000000001108e474
The listener is now like this. I retreive the user based on the token that was sent with the request. Therefore, I inject the request stack and my custom user provider in the listener's constructor. I do not think this is the source of the problem. However, if necessary, I'll edit the post and add all the remaining code (rest of the listener, services.yaml and user provider).
ItemListener
// ...
private $log;
/** #ORM\PreUpdate */
public function preUpdateHandler (Item $item, PreUpdateEventArgs $args)
{
$changeSet = $args->getEntityManager()->getUnitOfWork()->getEntityChangeSet($item)['quantity'];
$quantityChange = $changeSet[1] - $changeSet[0];
$transaction = new Transaction();
$transaction->setItem($item);
$transaction->setQuantityChange($quantityChange);
$request = $this->requestStack->getCurrentRequest();
$company = $this->userProvider->getUserByRequest($request);
$this->log = $transaction;
}
/** #ORM\PostUpdate */
public function postUpdateHandler(Item $item, LifecycleEventArgs $args)
{
$em = $args->getEntityManager();
$em->persist($this->log);
$em->flush();
}
I do not understand why retreiving the flush with retrieval of another entity leads to that error. When searching for an answer I found that that many recommend not to use flush() inside the postUpdate cycle but rather in postFlush. However, this method is not defined for Entity listeners according to the documentation and if possible, I'd like to stick to such a listener and not an event listener.
Thank you for any help. I also include the transaction entity code just in case.
Transaction Entity
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use App\DoctrineUtils\MagicAccessors;
use App\Entity\T\TIdentifier;
/**
* #ORM\Entity
* #ORM\Table(name="transaction")
*/
class Transaction
{
use TIdentifier;
use MagicAccessors;
/**
* #ORM\ManyToOne(targetEntity="Item")
* #ORM\JoinColumn(name="item_id", referencedColumnName="id", nullable=false)
*/
public $item;
/**
* #ORM\Column(type="decimal", length=14, precision=4, nullable=false)
*/
public $quantityChange;
/**
* #ORM\Column(type="datetime", nullable=true)
*/
private $createdTime;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\User")
* #ORM\JoinColumn(nullable=false)
*/
private $user;
public function __construct()
{
$this->createdTime = new \DateTime();
}
/**
* #param mixed $quantityChange
*/
public function setQuantityChange(int $quantityChange): void
{
$this->quantityChange = $quantityChange;
}
/**
* #param mixed $createdTime
*/
public function setCreatedTime($createdTime): void
{
$this->createdTime = $createdTime;
}
/** #ORM\PrePersist **/
public function onCreate() : void
{
$this->setCreatedTime(new \DateTime('now'));
}
public function setUser(?User $user): self
{
$this->user= $user;
return $this;
}
}
I found out that the problem was that another instance of the entity manager was instantiated in the getUserByRequest() function, where I log that the user's token was used. Apart others, I created inside it a new manager, persisted the entry and flushed the result. However, the new entity manager does not know about the unit of work inside the other entity manager inside the listener. Hence the undefined index error.
I tried to omit the persist and the flush part inside the user getter function, but that was not enough. In the end I solved the problem by passing the given instance entity manager from inside the listener to the getter function. So basically, I ended up calling this from the preUpdateHandler function inside the listener.
$em = $args->getEntityManager();
$company = $this->userProvider->getUserByRequest($request, $em);
Hope this helps if you find yourself in a similar pickle.

Persisting other entities inside preUpdate of Doctrine Entity Listener

For clarity I continue here the discussion started here.
Inside a Doctrine Entity Listener, in the preUpdate method (where I have access to both the old and new value of any field of the entity) I'm trying to persist an entity unrelated to the focal one.
Basically I have entity A, and when I change a value in one of the fields I want to write, in the project_notification table, the fields oldValue, newValue plus others.
If I don't flush inside the preUpdate method, the new notification entity does not get stored in DB. If I flush it I enter into a infinite loop.
This is the preUpdate method:
public function preUpdate(ProjectTolerances $tolerances, PreUpdateEventArgs $event)
{
if ($event->hasChangedField('riskToleranceFlag')) {
$project = $tolerances->getProject();
$em = $event->getEntityManager();
$notification = new ProjectNotification();
$notification->setValueFrom($event->getOldValue('riskToleranceFlag'));
$notification->setValueTo($event->getNewValue('riskToleranceFlag'));
$notification->setEntity('Entity'); //TODO substitute with the real one
$notification->setField('riskToleranceFlag');
$notification->setProject($project);
$em->persist($notification);
// $em->flush(); // gives infinite loop
}
}
Googling a bit I discovered that you cannot call the flush inside the listeners, and here it's suggested to store the stuff to be persisted in an array, to flush it later in the onFlush. Nonetheless it does not work (and probably it should not work, as the instance of the listener class gets destroyed after you call the preUpdate, so whatever you store in as protected attribute at the level of the class gets lost when you later call the onFlush, or am I missing something?).
Here is the updated version of the listener:
class ProjectTolerancesListener
{
protected $toBePersisted = [];
public function preUpdate(ProjectTolerances $tolerances, PreUpdateEventArgs $event)
{
$uow = $event->getEntityManager()->getUnitOfWork();
// $hasChanged = false;
if ($event->hasChangedField('riskToleranceFlag')) {
$project = $tolerances->getProject();
$notification = new ProjectNotification();
$notification->setValueFrom($event->getOldValue('riskToleranceFlag'));
$notification->setValueTo($event->getNewValue('riskToleranceFlag'));
$notification->setEntity('Entity'); //TODO substitute with the real one
$notification->setField('riskToleranceFlag');
$notification->setProject($project);
if(!empty($this->toBePersisted))
{
array_push($toBePersisted, $notification);
}
else
{
$toBePersisted[0] = $notification;
}
}
}
public function postFlush(LifecycleEventArgs $event)
{
if(!empty($this->toBePersisted)) {
$em = $event->getEntityManager();
foreach ($this->toBePersisted as $element) {
$em->persist($element);
}
$this->toBePersisted = [];
$em->flush();
}
}
}
Maybe I can solve this by firing an event from inside the listener with all the needed info to perform my logging operations after the flush...but:
1) I don't know if I can do it
2) It seems a bit an overkill
Thank you!
I give all the credits to Richard for pointing me into the right direction, so I'm accepting his answer. Nevertheless I also publish my answer with the complete code for future visitors.
class ProjectEntitySubscriber implements EventSubscriber
{
public function getSubscribedEvents()
{
return array(
'onFlush',
);
}
public function onFlush(OnFlushEventArgs $args)
{
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityUpdates() as $keyEntity => $entity) {
if ($entity instanceof ProjectTolerances) {
foreach ($uow->getEntityChangeSet($entity) as $keyField => $field) {
$notification = new ProjectNotification();
// place here all the setters
$em->persist($notification);
$classMetadata = $em->getClassMetadata('AppBundle\Entity\ProjectNotification');
$uow->computeChangeSet($classMetadata, $notification);
}
}
}
}
}
Don't use preUpdate, use onFlush - this allows you to access the UnitOfWork API & you can then persist entities.
E.g. (this is how I do it in 2.3, might be changed in newer versions)
$this->getEntityManager()->persist($entity);
$metaData = $this->getEntityManager()->getClassMetadata($className);
$this->getUnitOfWork()->computeChangeSet($metaData, $entity);
As David Baucum stated, the initial question referred to Doctrine Entity Listeners, but as a solution, the op ended up using an Event Listener.
I am sure many more will stumble upon this topic, because of the infinite loop problem.
For those that adopt the accepted answer, TAKE NOTE that the onFlush event (when using an Event Listener like above) is executed with ALL the entities that might be in queue for an update, whereas an Entity Listener is used only when doing something with the entity it was "assigned" to.
I setup a custom auditing system with symfony 4.4 and API Platform, and i managed to achieve the desired result with just an Entity Listener.
NOTE: Tested and working however, the namespaces and functions have been modified, and this is purely to demonstrate how to manipulate another entity inside a Doctrine Entity Listener.
// this goes into the main entity
/**
* #ORM\EntityListeners({"App\Doctrine\MyEntityListener"})
*/
<?
// App\Doctrine\MyEntityListener.php
namespace App\Doctrine;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\Security;
// whenever an Employee record is inserted/updated
// log changes to EmployeeAudit
use App\Entity\Employee;
use App\Entity\EmployeeAudit;
private $security;
private $currentUser;
private $em;
private $audit;
public function __construct(Security $security, EntityManagerInterface $em) {
$this->security = $security;
$this->currentUser = $security->getUser();
$this->em = $em;
}
// HANDLING NEW RECORDS
/**
* since prePersist is called only when inserting a new record, the only purpose of this method
* is to mark our object as a new entry
* this method might not be necessary, but for some reason, if we set something like
* $this->isNewEntry = true, the postPersist handler will not pick up on that
* might be just me doing something wrong
*
* #param Employee $obj
* #ORM\PrePersist()
*/
public function prePersist(Employee $obj){
if(!($obj instanceof Employee)){
return;
}
$isNewEntry = !$obj->getId();
$obj->markAsNewEntry($isNewEntry);// custom Employee method (just sets an internal var to true or false, which can later be retrieved)
}
/**
* #param Employee $obj
* #ORM\PostPersist()
*/
public function postPersist(Employee $obj){
// in this case, we can flush our EmployeeAudit object safely
$this->prepareAuditEntry($obj);
}
// END OF NEW RECORDS HANDLING
// HANDLING UPDATES
/**
* #see {https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/events.html}
* #param Employee $obj
* #param PreUpdateEventArgs $args
* #ORM\PreUpdate()
*/
public function preUpdate(Employee $obj, PreUpdateEventArgs $args){
$entity = $args->getEntity();
$changeset = $args->getEntityChangeSet();
// we just prepare our EmployeeAudit obj but don't flush anything
$this->audit = $this->prepareAuditEntry($obj, $changeset, $flush = false);
}
/**
* #ORM\PostUpdate()
*/
public function postUpdate(){
// if the preUpdate handler was called, $this->audit should exist
// NOTE: the preUpdate handler DOES NOT get called, if nothing changed
if($this->audit){
$this->em->persist($this->audit);
$this->em->flush();
}
// don't forget to unset this
$this->audit = null;
}
// END OF HANDLING UPDATES
// AUDITOR
private function prepareAuditEntry(Employee $obj, $changeset = [], $flush = true){
if(!($obj instanceof Employee) || !$obj->getId()){
// at this point, we need a DB id
return;
}
$audit = new EmployeeAudit();
// this part was cut out, since it is custom
// here you would set things to your EmployeeAudit object
// either get them from $obj, compare with the changeset, etc...
// setting some custom fields
// in case it is a new insert, the changedAt datetime will be identical to the createdAt datetime
$changedAt = $obj->isNewInsert() ? $obj->getCreatedAt() : new \DateTime('#'.strtotime('now'));
$changedFields = array_keys($changeset);
$changedCount = count($changedFields);
$changedBy = $this->currentUser->getId();
$entryId = $obj->getId();
$audit->setEntryId($entryId);
$audit->setChangedFields($changedFields);
$audit->setChangedCount($changedCount);
$audit->setChangedBy($changedBy);
$audit->setChangedAt($changedAt);
if(!$flush){
return $audit;
}
else{
$this->em->persist($audit);
$this->em->flush();
}
}
The idea is to NOT persist/flush anything inside preUpdate (except prepare your data, because you have access to the changeset and stuff), and do it postUpdate in case of updates, or postPersist in case of new inserts.
Theres a little hack I came across today. Maybe it helps to future generations.
So basicly, in onFlush Listener I cant store anything (because of deadlock or something similar If I call flush in another repository) and in postFlush i dont have access to change sets.
So I registered it as Subscriber with both events (onFlush, postFlush) implemented and just have class variable private array $entityUpdateBuffer = []; where I temp store Entities scheduled to update from onFlush event.
class MyEntityEventSubscriber implements EventSubscriber
{
private array $entityUpdateBuffer = [];
public function __construct(private MyBusiness $myBusiness)
{
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
Events::postFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
$this->entityUpdateBuffer = $uow->getScheduledEntityUpdates();
}
public function postFlush(PostFlushEventArgs $args): void
{
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($this->entityUpdateBuffer as $entity) {
if (!$entity instanceof MyEntity) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
// Call whatever that uses $entity->getId() as reference
$this->myBusiness->createChangeRecordWithEntityId(
$entity->getId(),
$changeSet,
)
}
}
}
Using an Lifecycle Listener instead of an EntityListener might be better suited in this case (I find that the symfony docs provide a better overview over the different options). This is due to onFlush, a very powerful event, not being available for EntityListeners. This event is invoked after all changesets are computed and before the database actions are executed.
In this answer I explore the options using an Entity Listener.
Using preUpdate: This event provides a PreUpdateEventArgs which makes it easy to find all values that are going to be changed. However this event is triggered within UnitOfWork#commit, after the inserts have been processed. Hence there is now no possibility to add a new entity to be persisted within current transaction.
Using preFlush: This event occurs at the beginning of a flush operation. Changesets might not yet be available, but we can compare the original values with the current ones. This approach might not be suitable when there are many changes that are needed. Here is an example implementation:
public function preFlush(Order $order, PreFlushEventArgs $eventArgs)
{
// Create a log entry when the state was changed
$entityManager = $eventArgs->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
$originalEntityData = $unitOfWork->getOriginalEntityData($order);
$newState = $order->getState();
if (empty($originalEntityData)) {
// We're dealing with a new order
$oldState = "";
} else {
$stateProperty = 'state';
$oldState = $originalEntityData[$stateProperty];
// Same behavior as in \Doctrine\ORM\UnitOfWork:720: Existing
// changeset is ignored when the property was changed
$entityChangeSet = $unitOfWork->getEntityChangeSet($order);
$stateChanges = $entityChangeSet[$stateProperty] ?? [];
if ($oldState == $newState && $stateChanges) {
$oldState = $stateChanges[0] ?? "";
$newState = $stateChanges[1] ?? "";
}
}
if ($oldState != $newState) {
$statusLog = $this->createOrderStatusLog($order, $oldState, $newState);
$unitOfWork->scheduleForInsert($statusLog);
$unitOfWork->computeChangeSet($entityManager->getClassMetadata('App\Entity\OrderStatusLog'), $statusLog);
}
}
Using postFlush/postUpdate: Using these events would lead to a second database transaction, which is undesirable.

Symfony - Avoid store related entities in preFlush Doctrine

I have an entity Subject:
/**
*
* #ORM\Table()
* #ORM\HasLifecycleCallbacks()
*/
class Subject
{
//... Some fields
/**
* #ORM\OneToMany(targetEntity="Subject", mappedBy="mark", cascade={"persist", "remove"})
*/
private $subjects;
private function calculateMarks()
{
//... Do something
// return array with (abilities => marks);
}
/**
* #ORM\PrePersist()
*/
public function prePersist(){
$now = new \DateTime();
$this->setCreatedAt( $now );
$this->setModifiedAt( $now );
}
/**
* #ORM\PreUpdate()
*/
public function preUpdate(){
$this->setModifiedAt( new \DateTime() );
$this->setUpdated(true);
}
/**
* #ORM\PreFlush()
*/
public function preFlush(){
$marks = calculateMarks();
foreach($marks as $ability => $score){
$mark = new Mark();
$mark->setSubject( $this );
$this->addMark( $score );
$mark->setAbility( $ability );
}
}
}
and the class Mark:
class Mark{
// Many fields
/**
* #ORM\ManyToOne(targetEntity="Subject", inversedBy="subjects")
* #ORM\JoinColumn(name="subject_id", referencedColumnName="id")
*/
private $subject;
}
My problem is that I calculate and I create the Marks in the preFlush event (this is done this because in the official documentation is said this about preUpdate event: "Changes to associations of the updated entity are never allowed in this event, since Doctrine cannot guarantee to correctly handle referential integrity at this point of the flush operation"). When I save one subject, all work fine, but when I save many Subjects at the same time in a webservice, some marks are stored in the database many times.
The webservice action below:
public function setSubjects(Request $request)
{
//... Do something
$subjects = $request["Subjects"];
foreach($subjects as $s){
$em = $this->getDoctrine()->getManager();
//... Do something
$em->persist($s);
$em->flush();
}
return new JsonResponse($response);
}
Has anybody an idea of how could I avoid this behavior in the preFlush event?
Thanks in advance.
I always try to avoid LifecycleCallbacks unless it's simple and i'm only changing properties in the same entity.
to solve your issue i would create a function calculateMarks() inside the entity and tweak my loop to be something like
$em = $this->getDoctrine()->getManager();
foreach($subjects as $s){
//... Do something
$s->calculateMarks();
$em->persist($s);
}
$em->flush();
NOTICE
avoid $em = $this->getDoctrine()->getManager(); & $em->flush(); inside the loop

Symfony - Efficient access control for (dynamic) hierarchical roles

I need some advice on how to handle access control for the following scenario:
Corporation
Has one or many companies
Has one or many ROLE_CORP_ADMIN
Company
Has one or many regions.
Has one or many ROLE_COMPANY_ADMIN.
Region:
Has zero or many stores.
Has one or many ROLE_REGION_ADMIN.
Store:
Has zero or many assets.
Has one or many ROLE_STORE_ADMIN.
Has zero or many ROLE_STORE_EMPLOYEE.
Has zero or many ROLE_STORE_CUSTOMER (many is better).
The application should support many corporations.
My instinct is to create either a many-to-many relationship per entity for their admins (eg region_id, user_id). Depending on performance, I could go with a more denormalized table with user_id, corporation_id, company_id, region_id, and store_id. Then I'd create a voter class (unanimous strategy):
public function vote(TokenInterface $token, $object, array $attributes)
{
// If SUPER_ADMIN, return ACCESS_GRANTED
// If User in $object->getAdmins(), return ACCESS_GRANTED
// Else, return ACCESS_DENIED
}
Since the permissions are hierarchical, the getAdmins() function will check all owners for admins as well. For instance:
$region->getAdmins() will also return admins for the owning company, and corporation.
I feel like I'm missing something obvious. Depending on how I implement the getAdmins() function, this approach will require at least one hit to the db every vote. Is there a "better" way to go about this?
Thanks in advance for your help.
I did just what I posed above, and it is working well. The voter was easy to implement per the Symfony cookbook. The many-to-many <entity>_owners tables work fine.
To handle the hierarchical permissions, I used cascading calls in the entities. Not elegant, not efficient, but not to bad in terms of speed. I'm sure refactor this to use a single DQL query soon, but cascading calls work for now:
class Store implements OwnableInterface
{
....
/**
* #ORM\ManyToMany(targetEntity="Person")
* #ORM\JoinTable(name="stores_owners",
* joinColumns={#ORM\JoinColumn(name="store_id", referencedColumnName="id", nullable=true)},
* inverseJoinColumns={#ORM\JoinColumn(name="person_id", referencedColumnName="id")}
* )
*
* #var ArrayCollection|Person[]
*/
protected $owners;
...
public function __construct()
{
$this->owners = new ArrayCollection();
}
...
/**
* Returns all people who are owners of the object
* #return ArrayCollection|Person[]
*/
function getOwners()
{
$effectiveOwners = new ArrayCollection();
foreach($this->owners as $owner){
$effectiveOwners->add($owner);
}
foreach($this->getRegion()->getOwners() as $owner){
$effectiveOwners->add($owner);
}
return $effectiveOwners;
}
/**
* Returns true if the person is an owner.
* #param Person $person
* #return boolean
*/
function isOwner(Person $person)
{
return ($this->getOwners()->contains($person));
}
...
}
The Region entity would also implement OwnableInterface and its getOwners() would then call getCompany()->getOwners(), etc.
There were problems with array_merge if there were no owners (null), so the new $effectiveOwners ArrayCollection seems to work well.
Here is the voter. I stole most of the voter code and OwnableInterface and OwnerInterface from KnpRadBundle:
use Acme\AcmeBundle\Security\OwnableInterface;
use Acme\AcmeBundle\Security\OwnerInterface;
use Acme\AcmeUserBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class IsOwnerVoter implements VoterInterface
{
const IS_OWNER = 'IS_OWNER';
private $container;
public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container) {
$this->container = $container;
}
public function supportsAttribute($attribute)
{
return self::IS_OWNER === $attribute;
}
public function supportsClass($class)
{
if (is_object($class)) {
$ref = new \ReflectionObject($class);
return $ref->implementsInterface('Acme\AcmeBundle\Security\OwnableInterface');
}
return false;
}
public function vote(TokenInterface $token, $object, array $attributes)
{
foreach ($attributes as $attribute) {
if (!$this->supportsAttribute($attribute)) {
continue;
}
if (!$this->supportsClass($object)) {
return self::ACCESS_ABSTAIN;
}
// Is the token a super user? This will check roles, not user.
if ( $this->container->get('security.context')->isGranted('ROLE_SUPER_ADMIN') ) {
return VoterInterface::ACCESS_GRANTED;
}
if (!$token->getUser() instanceof User) {
return self::ACCESS_ABSTAIN;
}
// check to see if this token is a user.
if (!$token->getUser()->getPerson() instanceof OwnerInterface) {
return self::ACCESS_ABSTAIN;
}
// Is this person an owner?
if ($this->isOwner($token->getUser()->getPerson(), $object)) {
return self::ACCESS_GRANTED;
}
return self::ACCESS_DENIED;
}
return self::ACCESS_ABSTAIN;
}
private function isOwner(OwnerInterface $owner, OwnableInterface $ownable)
{
return $ownable->isOwner($owner);
}
}

Resources