Doctrine lifecycle callbacks not working with EasyAdminBundle - php

I am using Symfony 4 with EasyAdminBundle to create a simple administrative interface. I'm having a few problems trying to automatically set the value for createdAt and updatedAt columns. When creating/updating an entity within EasyAdmin, the configured Doctrine lifecycle callbacks are not called. For example, here's a simple entity that is managed with EasyAdmin, note the lifecycle callback hooks:
<?php
namespace App\Entity;
/**
* #ORM\Entity(repositoryClass="App\Repository\ProductRepository")
* #ORM\HasLifecycleCallbacks
*/
class Product
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
// Additional column configuration removed for brevity
/**
* #ORM\Column(type="datetime")
*/
private $createdAt;
/**
* #ORM\Column(type="datetime", nullable=true)
*/
private $updatedAt;
// Additional getters/setters removed for brevity
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(?\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeInterface $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
/**
* #ORM\PrePersist
*/
public function onPrePersist(): void
{
$this->createdAt = new \DateTime();
}
/**
* #ORM\PreUpdate
*/
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTime();
}
}
When I create a new product within EasyAdmin, onPrePersist() is not called, and when I edit an existing product with EasyAdmin, onPreUpdate() is not called.
If I create a new product the "traditional" way, the lifecycle callbacks work exactly as expected. For example:
<?php
$product = new Product();
$product->setTitle('Test Product');
$product->setDescription('Test description');
// Doctrine lifecycle callbacks work as expected
$this->getDoctrine()->getManager()->persist($product);
Is EasyAdminBundle bypassing Doctrine lifecycle callbacks? If so, why? How do I utilize Doctrine lifecycle callbacks within Doctrine entities managed by EasyAdminBundle?
I understand there is documentation to do something similar with an AdminController:
https://symfony.com/doc/master/bundles/EasyAdminBundle/book/complex-dynamic-backends.html#update-some-properties-for-all-entities
But again, why do I need to do this when we already have Doctrine lifecycle callbacks. My other problem with using an AdminController and extending various methods, persistEntity() within AdminController is never called either.
What am I missing?
Any help would be greatly appreciated! Cheers!

Related

Attempt to update entity without persisting on it DOCTRINE

I have a Product entity with a ManyToOne relationship with Category.
use Doctrine\ORM\Mapping as ORM;
class Product
{
/**
* #ORM\ManyToOne(targetEntity="App\Entity\Category")
* #ORM\JoinColumn(nullable=true)
*/
private $category;
}
class Category
{
use BlameableTrait;
...
}
The Category entity implements a trait, with properties to record when a record is created, updated, etc.
use App\Entity\User\User;
use Gedmo\Mapping\Annotation as Gedmo;
trait BlameableTrait
{
/**
* #Gedmo\Blameable(on="create")
* #ORM\ManyToOne(targetEntity="App\Entity\User\User")
* #ORM\JoinColumn(nullable=true, onDelete="SET NULL")
* #var User
*/
private $createdBy;
/**
* #var User|null
*
* #Gedmo\Blameable(on="update")
* #ORM\ManyToOne(targetEntity="App\Entity\User\User")
* #ORM\JoinColumn(nullable=true, onDelete="SET NULL")
*/
private $updatedBy;
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
public function setUpdatedBy(?User $updatedBy): self
{
$this->updatedBy = $updatedBy;
return $this;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
}
When I try to update the product entity I have an error indicating that an error has occurred while trying to update the Category entity, but I have not indicated that it should be updated.
This only happens to me in the production environment and randomly, one time it works, another time it doesn't.
Locally, debugging the symfony profiler, for the same curl, only one update is done on the Product entity, which is fine.
I don't understand where Symfony or Doctrine try to update the Category entity.
Both the production and local environments run on the same Docker image.

Symfony Doctrine2 manyToMany relationship not removed - Specific to SQLite

I have several classes using a Taggable trait to set up a tag system common to several doctrine entities (Project, Note, ...).
The relationship between these entities and these tags is a ManyToMany relationship that I can not make multi-directional.
My problem: When I delete a Project entity, it is removed from the project table, but the relationships in the project_tag table between this project and the tags are not deleted. Then, if I create a new Project entity, an exception is thrown.
An exception exists while executing 'INSERT INTO project_tag (project_id, tag_id) VALUES (?,?)' With params [2, 4]:
SQLSTATE [23000]: Integrity constraint violation: 19 UNIQUE constraint failed: project_tag.project_id, project_tag.tag_id
Entities :
Tag
/**
* Tag
*
* #ORM\Table(name="tag")
* #ORM\Entity(repositoryClass="AppBundle\Repository\TagRepository")
*/
class Tag
{
/**
* #var int
*
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #var string
*
* #ORM\Column(name="name", type="string", length=255, unique=true)
*/
private $name;
/**
* #ORM\Column(name="last_use_at", type="datetime", nullable=false)
* #var \DateTime
*/
private $lastUseAt;
public function __construct()
{
$this->lastUseAt = new \DateTime();
}
public function __toString()
{
return $this->name;
}
/**
* Get id
*
* #return int
*/
public function getId()
{
return $this->id;
}
/**
* Set name
*
* #param string $name
*
* #return Tag
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* #return string
*/
public function getName(): string
{
return $this->name;
}
/**
* #return \DateTime
*/
public function getLastUseAt(): \DateTime
{
return $this->lastUseAt;
}
/**
* #param \DateTime $lastUseAt
*/
public function setLastUseAt(\DateTime $lastUseAt)
{
$this->lastUseAt = $lastUseAt;
}
}
Taggable
trait Taggable
{
/**
* #var ArrayCollection
*
* #ORM\ManyToMany(targetEntity="AppBundle\Entity\Tag", cascade={"persist"})
*/
protected $tags;
/**
* Add tag
*
* #param Tag $tag
*
* #return $this
*/
public function addTag(Tag $tag)
{
$tag->setLastUseAt(new \DateTime());
$this->tags[] = $tag;
return $this;
}
/**
* Remove tag
*
* #param Tag $tag
*/
public function removeTag(Tag $tag)
{
$this->tags->removeElement($tag);
}
/**
* Get tags
*
* #return \Doctrine\Common\Collections\Collection
*/
public function getTags()
{
return $this->tags;
}
}
Project
/**
* Project
*
* #ORM\Table(name="project")
* #ORM\Entity(repositoryClass="AppBundle\Repository\ProjectRepository")
*/
class Project
{
use Taggable;
}
Note
class Note
{
use Taggable;
}
Is this the only solution or is my annotation incomplete / incorrect?
I tried with JoinColumns, JoinTable and onDelete = "cascade" but nothing works.
In the meantime, I dodged the problem with this instruction placed before the suppresion.
$project->getTags()->clear();
Full code of the action in the controller :
/**
* #Route("/project/{id}/delete", name="project_delete")
*/
public function deleteAction($id) {
$em = $this->getDoctrine()->getManager();
$project = $em->getRepository('AppBundle:Project')->find($id);
if(!$project) {
return $this->redirectToRoute('index');
}
$project->getTags()->clear();
$em->remove($project);
$em->flush();
return $this->redirectToRoute('index');
}
I think I found a better solution: you can set the PRAGMA within Doctrine configuration. Like:
doctrine:
dbal:
# configure these for your database server
driver: 'pdo_sqlite'
#server_version: '5.7'
#charset: utf8mb4
#default_table_options:
#charset: utf8mb4
#collate: utf8mb4_unicode_ci
url: '%env(resolve:DATABASE_URL)%'
options:
'PRAGMA foreign_keys': 'ON'
I just tried it on my Symfony 4 application, re-created the database and tested using DB Browser for SQLite and it works as I expected.
Hope this helps
I managed to fix the problem. Here's my solution working for SQLite conections.
Create an eventListener listening on the kernel.request event :
namespace AppBundle\EventListener;
use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
class RequestListener
{
/**
* #var Registry
*/
private $doctrine;
public function __construct(Registry $doctrine)
{
$this->doctrine = $doctrine;
}
public function onKernelRequest(GetResponseEvent $event)
{
$this->doctrine->getConnection()->exec('PRAGMA foreign_keys = ON');
}
}
Service declaration
app.event_listener.request_listener:
class: AppBundle\EventListener\RequestListener
arguments:
- '#doctrine'
tags:
- { name: kernel.event_listener, event: kernel.request }
I think the problem is that you have your trait Taggable set as the owning side of the ManyToMany relationship but your are deleting the inverse side and expecting something to happen as a result. Doctrine will only check the owning side of the relationship in order to persist any changes. See here for docs on this.
You can solve by making the Taggable the inverse side of each of your relationships, or by manually telling doctrine to delete the owning side.
The first solution will probably not work for you since you won't (easily) specify multiple inverse sides. (Are you sure a trait is the right way to go for this??)
The second solution is easy. In your entities like Project for your deleteTag($tag) function, call a delete function on the owning side (e.g., deleteProject($project). You will have to create if one does not exist.
class Project
{
use Taggable;
public function deleteTag($tag)
{
$this->tags->removeElement($tag);
// persist on the owning side
$tag->deleteProject($this);
}
}
EDIT:
After seeing full code, it looks like you are deleting correctly. Now you need to tell doctrine to carry that through. See this post for full details, but basically you can change your trait to this:
trait Taggable
{
/**
* #var ArrayCollection
*
* #ORM\ManyToMany(
* targetEntity="AppBundle\Entity\Tag",
* cascade={"persist"},
* onDelete="CASCADE"
* )
*/
protected $tags;
// ...
}

Symfony VichUploaderBundle: File name could not be generated

I am using VichUploader to upload files within a symfony project. In configuration i use (copied from documentation):
service: vich_uploader.namer_property
options: { property: 'slug'}
In my entity i generate the slugs automatically with Gedmo/Sluggable:
/**
* #Gedmo\Slug(fields={"title"}, updatable=false)
* #ORM\Column(type="string", length=100, nullable=false)
*/
protected $slug;
But when trying to save the entity i get the following error 500:
File name could not be generated: property slug is empty.
If i set the property to 'title' it works. Did i forget a configuration parameter or something else to get it working with the Gedmo slug?
I'm having the same issue at the moment, as a workaround, I've slightly changed the slug getter in the entity class:
use Gedmo\Sluggable\Util\Urlizer;
class Event
{
// ...
/**
* #var string
*
* #Gedmo\Slug(fields={"name"})
* #ORM\Column(name="slug", type="string", length=128, unique=true)
*/
private $slug;
// ...
public function getSlug()
{
if (!$this->slug) {
return Urlizer::urlize($this->getName());
}
return $this->slug;
}
}
That did the trick.
Unfortunately, there're a couple of drawbacks:
If you ever want to update sluggable behaviour in the annotation to include additional properties, you'll have to update the getter as well.
This method lacks a check against the database: if there's already a record in the database with the same name urlizer in the getter won't be able to add an increment to the file name, previously saved file may be overwritten! As a workaround, you can add unique=true to sluggable properties.
VichUploader listens to the prePersist and preUpdate events, whereas Sluggable listens to the onFlush event. Because prePersist and preUpdate are called before onFlush, it isn't possible to do this purely using configuration.
However, if your file field is nullable, you can work around it by changing your controller code. When you receive the submitted form with the file, remove the file, save the entity, then re-add the file and save the entity again. On the second save, the slug will already be set, so VichUploader will be able to save the file fine.
if ($form->isSubmitted() && $form->isValid()) {
if ($file = $entity->getFile()) {
$entity->setFile(null);
}
$em = $this->getDoctrine()->getManager();
$em->persist($entity);
$em->flush();
if ($file) {
$entity->setFile($file);
$em->persist($entity);
$em->flush();
}
// ...
}
This only works when adding a new file. If you subsequently change the slug and resave the entity without uploading a new file, the filename is not updated.
I was having the same problem uploading a document for which I needed the fileName to be the slug.
I was using Gedmo annotations to generate the slug, however this only triggers on flush and the vichUploader namer is triggered upon persist.
The easiest way for me to get this working was to not use the Gedmo\Sluggable annotation but rather create a prePersist listener on my Document object and use the Cocur\Slugify library.
So here is the code.
My Document Entity:
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
/**
* #ORM\Entity(repositoryClass="App\Repository\DocumentRepository")
* #Vich\Uploadable
* #ORM\EntityListeners({"App\Listeners\DocumentListener"})
*/
class Document
{
use TimestampableEntity;
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\Column(type="string", length=100, nullable=false)
*/
private $fileName;
/**
* #Vich\UploadableField(mapping="document", fileNameProperty="fileName")
* #var File
*/
private $documentFile;
/**
* #ORM\Column(type="string", length=100, unique=true)
*/
private $slug;
/**
*/
public function getDocumentFile(): ?File
{
return $this->documentFile;
}
/**
* #param File $documentFile
* #return Document
* #throws \Exception
*/
public function setDocumentFile(File $documentFile = null): Document
{
$this->documentFile = $documentFile;
if($documentFile){
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
/**
* #return mixed
*/
public function getFileName()
{
return $this->fileName;
}
/**
* #param mixed $fileName
*/
public function setFileName($fileName): void
{
$this->fileName = $fileName;
}
}
And the listener :
namespace App\Listeners;
use App\Entity\Document;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Cocur\Slugify\Slugify;
class DocumentListener
{
public function prePersist(Document $document, LifecycleEventArgs $args)
{
$slugify = new Slugify();
if(!empty($document->getDocumentFile())){
$originalName = pathinfo($document->getDocumentFile()->getClientOriginalName(), PATHINFO_FILENAME);
$slug = $slugify->slugify($originalName);
$document->setSlug($slug);
}
}
}
So far I have not had any problems.
Let me know if this works for you
By default the doctrine extensions bundle does not attach any listener:
http://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html#activate-the-extensions-you-want
You should configure it to get sluggable working:
stof_doctrine_extensions:
orm:
default:
sluggable: true

Are Doctrine-generated association properties always of type ArrayCollection?

I am currently trying to filter a Doctrine-generated association property by using Criteria as described in the Doctrine manual: Filtering Collections
Here is some sample code: A person who owns several cars. For the sake of simplicity we save the date when the person bought and sold the car in the car entity instead of creating an association entity.
class Car
{
/**
* #ORM\ManyToOne(targetEntity="Person", inversedBy="cars")
* #ORM\JoinColumn(name="owner_id", referencedColumnName="id", nullable=false)
*/
private $owner;
/**
* #ORM\Column(name="bought", type="date")
*/
private $bought;
/**
* #ORM\Column(name="sold", type="date")
*/
private $sold;
}
class Person
{
/**
* #ORM\OneToMany(targetEntity="Car", mappedBy="owner")
*/
private $cars;
public function __construct
{
$this->cars = new ArrayCollection();
}
/**
* Get cars
*
* #return \Doctrine\Common\Collections\Collection
*/
public function getCars()
{
return $this->cars;
}
public function getCarsOwnedAtDate(\DateTime $date)
{
$allCars = $this->getCars();
$criteria = Criteria::create()->where(
Criteria::expr()->andX(
Criteria::expr()->lte("bought", $date),
Criteria::expr()->gte("sold", $date)
)
);
return $allCars->matching($criteria);
}
}
The documentation for getCars says that it returns a Collection. Note that this documentation is auto-generated by Doctrine.
So when I call matching on Collection in my getCarsOwnedAtDate method my IDE issues a warning that Collection does not have a matching method. Turns out, it is right.
The object returned by getCars is an ArrayCollection and has a matching method so the code runs fine.
My question is: Can I rely on the fact that methods auto-generated by Doctrine will always return an ArrayCollection? Or will the above code fail in a certain case? If it does always return ArrayCollection, then why does the PhpDoc comment not mention this class explicitly?

Symfony2: Where place slug and timestamp methods?

I am coding a service which will handle articles (CRUD).
The persistence layer is handles by an ArticleManager >which does Repository and CRUD actions.
Now I want to implement two attributes: createdAt and >updatedAt
My question is now where I should place them:
In the entity, in the ArticleManager, somewhere else?
Best Regards,
Bodo
Ah,
I see, the FOSUserBundle handles this task with an EventListener:
https://github.com/FriendsOfSymfony/FOSUserBundle/blob/master/Entity/UserListener.php
But thank you for youre help :)
<?php
namespace LOC\ArticleBundle\Entity;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use LOC\ArticleBundle\Model\ArticleInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ArticleListener implements EventSubscriber
{
private $articleManager;
private $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function getSubscribedEvents()
{
return array(
Events::prePersist,
Events::preUpdate,
);
}
public function prePersist(LifecycleEventArgs $args)
{
$article = $args->getEntity();
$article->setCreatedAt(new \DateTime());
$this->articleManager->updateArticle($article);
}
public function preUpdate(PreUpdateEventArgs $args)
{
$article = $args->getEntity();
$article->setUpdatedAt(new \DateTime());
$this->articleManager->updateArticle($article);
}
}
Well, there is a bundle for such stuff, the DoctrineExtensionsBundle. It got Timestampable and slugable.
If you want to do it on your own, the place is definitly in the Entity itself, as you don't want to mess around in your controller. Here is how I do the Timestampable as I don't use the DoctrineExtensionsBundle:
/**
* #ORM\Entity
* #ORM\Table(name="entity")
* #ORM\HasLifecycleCallbacks
*/
class Entity {
// ...
/**
* #ORM\Column(name="created_at", type="datetime", nullable=false)
*/
protected $createdAt;
/**
* #ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
protected $updatedAt;
/**
* #ORM\prePersist
*/
public function prePersist() {
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
/**
* #ORM\preUpdate
*/
public function preUpdate() {
$this->updatedAt = new \DateTime();
}
// ...
}
As for my decision not to use the Bundle: When symfony2 was released as stable, this bundle didn't exist (or it wasn't stable, I don't remember) so I started doing it on my own like shown below. As it is little overhead, I kept doing it like this and never felt the need to change it. If you need Slugable or want to keep it simply, try the bundle!
In the entity, since that's where they belong logically.

Categories