#[ApiResource] attribute causing exception in OneToMay resource - symfony

I have two related doctrine entities:
Content
#[ApiResource(operations: [new Get(), new Patch(), new Delete(), new Put(), new Post(uriTemplate: '/contents/add_text', controller: ContentTextPersist::class, read: false, openapiContext: ['description' => 'This endpoint provides a way to add content text and variants to a content object without incurring circular reference issue as a result of the nested nature of the Content -> ContentText and ContentTextVariants.', 'summary' => 'Persist new content text item and it\'s translations.']), new Post(), new GetCollection()], order: ['createdAt' => 'DESC'], normalizationContext: ['groups' => ['content:read']], denormalizationContext: ['groups' => ['content:write']], filters: ['translation.groups'])]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Content extends AbstractTranslatable
{
....
/**
* #var \Doctrine\Common\Collections\Collection<int, \App\Entity\ContentText>|\App\Entity\ContentText[]
*/
#[ORM\OneToMany(targetEntity: ContentText::class, mappedBy: 'content', orphanRemoval: true, cascade: ['persist'])]
#[Groups(['content:read', 'content:write'])]
private iterable $text;
...
public function __construct()
{
parent::__construct();
$this->text = new ArrayCollection();
}
...
/**
* #return Collection|ContentText[]
*/
public function getText() : Collection
{
return $this->text;
}
public function addText(ContentText $text) : self
{
if (!$this->text->contains($text)) {
$this->text[] = $text;
$text->setContent($this);
}
return $this;
}
public function removeText(ContentText $text) : self
{
if ($this->text->removeElement($text)) {
// set the owning side to null (unless already changed)
if ($text->getContent() === $this) {
$text->setContent(null);
}
}
return $this;
}
}
ContentText
#[ApiResource(operations: [new Patch(), new Delete(), new Get(), new GetCollection()], order: ['createdAt' => 'DESC'], paginationPartial: true, normalizationContext: ['groups' => ['contentText:read']], denormalizationContext: ['groups' => ['contentText:write']], filters: ['translation.groups'])]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class ContentText
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[Groups(['contentText:read', 'content:read'])]
private ?int $id = null;
public function getId() : ?int
{
return $this->id;
}
}
Would anyone know why if I remove #[ApiResource] attribute from ContentText entity then I'm able to get the collection of ContentText related to Content otherwise I get the error below:
"Unable to generate an IRI for the item of type \"App\\Entity\\ContentText\""

Related

Only return collection when field is false

I have the following entity in my api-platform:
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
],
normalizationContext: ['groups' => ['read']],
)]
#[ApiFilter(
filterClass: SearchFilter::class,
properties: [
"id" => SearchFilterInterface::STRATEGY_EXACT,
'hash' => SearchFilterInterface::STRATEGY_EXACT,
'used' => SearchFilterInterface::STRATEGY_EXACT,
]
)]
class ResetPasswordHashes
{
#[ORM\Id, ORM\Column(name: "id"), ORM\GeneratedValue]
#[Groups(['read'])]
public int $id;
#[ORM\Column(name: "hash", length: 255)]
#[Groups(['read'])]
public string $hash;
#[ORM\Column(name: 'used')]
#[Groups(['read'])]
public bool $used;
#[ORM\OneToOne(targetEntity: Contact::class)]
#[ORM\JoinColumn(name: 'contact_id', referencedColumnName: 'contact_id')]
#[Groups(['read'])]
public ?Contact $contact;
#[ORM\Column(name: "created_at")]
#[Groups(['read'])]
public ?\DateTime $createdAt;
This is just an excerpt of the relevant lines. Idealy I only want to return the information when the the $used is false. Is there a way to set up the entity where I protect the $contact information if $used == false?
Here is an example to protect the $contact information if $used is false:
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
],
normalizationContext: ['groups' => ['read']],
)]
class ResetPasswordHashes
{
//...
public bool $used;
#[Groups(['read'])]
public ?Contact $contact;
//...
#[Serializer\Groups({"read"})]
public function isUsed(): bool
{
return $this->used;
}
}
The isUsed method is decorated with the #Serializer\Groups annotation, which specifies that it should be included in the "read" group.
Then in your controller you can use the Symfony's serializer component to handle the serialization and apply the isUsed method as a condition for the inclusion of the $contact property:
/**
* #Route("/reset-password-hashes/{id}", name="reset_password_hashes_get", methods={"GET"})
*/
public function get(ResetPasswordHashes $resetPasswordHashes, SerializerInterface $serializer)
{
$data = $serializer->normalize($resetPasswordHashes, null, [
'groups' => ['read'],
'used' => $resetPasswordHashes->isUsed() ? ['contact'] : [],
]);
return new JsonResponse($data);
}
This way the $contact property will only be included in the serialization if $used is false.

Merge form CollectionType with existing collection

I would need some help about management of CollectionType. In order to make my question as clear as possible, I will change my situation to fit the official Symfony documentation, with Tasks and Tags.
What I would like :
A existing task have alrady some tags assigned to it
I want to submit a list of tags with an additional field (value)
If the submitted tags are already assigned to the task->tags collection, I want to update them
It they are not, I want to add them to the collection with the submitted values
Existing tags, no part of the form, must be kept
Here is the problem :
All task tags are always overwritten by submitted data, including bedore the handleRequest method is called in the controller.
Therefore, I can't even compare the existing data using the repository, since this one already contains the collection sent by the form, even at the top of the update function in the controller.
Entity wize, this is a ManyToMany relation with an additional field (called value), so in reality, 2 OneToMany relations. Here are the code :
Entity "Task"
class Task
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'task', targetEntity: TaskTags::class, orphanRemoval: false, cascade: ['persist'])]
private Collection $TaskTags;
/**
* #return Collection<int, TaskTags>
*/
public function getTaskTags(): Collection
{
return $this->TaskTags;
}
public function addTaskTag(TaskTags $TaskTag): self
{
// I have voluntarily remove the presence condition during my tests
$this->TaskTags->add($TaskTag);
$TaskTag->setTask($this);
return $this;
}
public function removeTaskTag(TaskTags $TaskTag): self
{
if ($this->TaskTags->removeElement($TaskTag)) {
// set the owning side to null (unless already changed)
if ($TaskTag->getTask() === $this) {
$TaskTag->setTask(null);
}
}
return $this;
}
}
Entity "Tag"
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'tag', targetEntity: TaskTags::class, orphanRemoval: false)]
private Collection $TaskTags;
/**
* #return Collection<int, TaskTags>
*/
public function getTaskTags(): Collection
{
return $this->TaskTags;
}
public function addTaskTag(TaskTags $TaskTag): self
{
$this->TaskTags->add($TaskTag);
$TaskTag->setTag($this);
return $this;
}
public function removeTaskTag(TaskTags $TaskTag): self
{
if ($this->TaskTags->removeElement($TaskTag)) {
// set the owning side to null (unless already changed)
if ($TaskTag->getTag() === $this) {
$TaskTag->setTag(null);
}
}
return $this;
}
}
Entity "TaskTags"
class TaskTags
{
#[ORM\Id]
#[ORM\ManyToOne(inversedBy: 'TaskTags')]
#[ORM\JoinColumn(nullable: false)]
private Task $task;
#[ORM\Id]
#[ORM\ManyToOne(inversedBy: 'TaskTags')]
#[ORM\JoinColumn(nullable: false)]
private Tag $tag;
// The addional field
#[ORM\Column(nullable: true)]
private ?int $value = null;
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): self
{
if(null !== $task) {
$this->task = $task;
}
return $this;
}
public function getTag(): ?Tag
{
return $this->tag;
}
public function setTag(?Tag $tag): self
{
if(null !== $tag) {
$this->tag = $tag;
}
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(?string $value): self
{
$this->value = $value;
return $this;
}
}
FormType "TaskFormType"
class TaskFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
...
->add('TaskTags', CollectionType::class, [
'by_reference' => false,
'entry_type' => TaskTagsFormType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Task::class,
'csrf_protection' => false
]);
}
}
FormType "TaskTagsFormType"
class TaskTagsFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('task')
->add('tag')
->add('value')
;
}
Controller
#[Route('/tasks/edit/{id}/tags', name: 'app_edit_task')]
public function editasktags(Request $request, EntityManagerInterface $em, TaskTagsRepository $TaskTagsRepo): Response
{
...
// Create an ArrayCollection of the current tags assigned to the task
$task = $this->getTask();
// when displaying the form (method GET), this collection shows correctly the tags already assigned to the task
// when the form is submitted, it immediately becomes the collection sent by the form
$ExistingTaskTags = $TaskTagsRepo->findByTask($task);
$form = $this->createForm(TaskFormType::class, $task);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// here begins all I have tried ... I was trying to compare the value in the DB and in the form, but because of the repo being overwritten I can't
$task = $form->getData();
$SubmittedTaskTags = $userForm->getTaskTags();
$CalculatedTaskTags = new ArrayCollection();
foreach ($ExistingTaskTags as $ExistingTaskTag) {
foreach ($SubmittedTaskTags as $SubmittedTaskTag) {
if ($ExistingTaskTag->getTag()->getId() !== $SubmittedTaskTag->getTag()->getId()) {
// The existing tag is not the same as submitted, keeping it as it in a new collection
$CalculatedTaskTags->add($ExistingTaskTag);
} else {
// The submitted tag is equal to the one in DB, so adding the submitted one
$SubmittedTaskTag->setTask($task);
$CalculatedTaskTags->add($SubmittedTaskTag);
}
}
}
$em->persist($task);
$em->flush();
}
return $this->render('task/edittasktags.twig.html', [
'form' => $form,
'task' => $this->getTask()
]);
}
My main issue is that I am not able to get the existing data one the form has been submitted, in order to perform a "merge"
I have tried so many things.
One I did not, and I'd like to avoid : sending the existing collection as hidden fields.
I don't like this at all since if the data have been modified in the meantime, we are sending outdated data, which could be a mess in multi tab usage.
Thank you in advance for your help, I understand this topic is not that easy.
NB : the code I sent it not my real code / entity. I've re written according to the Symfony doc case, so there could be some typo here and there, apologize.
Solution found.
I added 'mapped' => false in the FormType.
And I was able to retrieve the form data using
$SubmittedTags = $form->get('TaskTags')->getData();
The repository was not overwritten by the submitted collection.

Symfony 6 & VichUploaderBundle : "default" UploadedFile

I'm learning Symfony by working on a personal project. I'm using Symfony 6.1.
I have a page containing two forms :
the "main" form ($bookForm) deals the Book entity and persist it to the database
the second one ($isbnForm) takes a string and search the Google Books API.
When submitting the second form ($isbnForm), the page is reloaded and $bookForm is preloaded with data.
Here is the code of the BookController :
#[Route('/create', name: 'create', methods: ['GET', 'POST'])]
public function new(
Request $request,
BookshelfRepository $bookshelfRepository,
AuthorRepository $authorRepository,
PublisherRepository $publisherRepository,
BookRepository $bookRepository
): Response {
$this->denyAccessUnlessGranted('edit', $this->getUser());
$book = new Book();
// Retrieving the Bookshelf passed along the request
// and associating it to the Book
if ($request->query->get('bksid')) {
$bookshelf = $bookshelfRepository->findOneBy(['ulid' => $request->query->get('bksid')]);
$book->setBookshelf($bookshelf);
}
// Dealing with the ISBN form
$isbnForm = $this->createForm(IsbnType::class);
$isbnForm->handleRequest($request);
if ($isbnForm->isSubmitted() && $isbnForm->isValid()) {
$isbnTools = new IsbnTools();
$isbn = $isbnTools->format($isbnForm->getData()['isbn']);
$book->setIsbn($isbn);
// Getting book's details from the Google Books API using the ISBN
$gbapi = new GoogleBooksApiUtils();
$details = $gbapi->gettingVolumeInfoByIsbn($isbn);
$book->setTitle($details->getTitle());
$book->setSubtitle($details->getSubtitle());
$book->setDescription($details->getDescription());
$book->setPages($details->getPageCount());
$book->setPublicationDate(substr($details->getPublishedDate(), 0, 4));
if ($details->getPublisher()) {
$publisher = $publisherRepository->findOneBy(['name' => $details->getPublisher()]);
if (!$publisher) {
$publisher = new Publisher();
$publisher->setName($details->getPublisher());
$publisherRepository->save($publisher, true);
}
$book->setPublisher($publisher);
}
foreach ($details->getAuthors() ?? [] as $dga) {
$author = $authorRepository->findOneBy(['name' => $dga]);
if (!$author) {
$author = new Author();
$author->setName($dga);
$authorRepository->save($author, true);
}
$book->addAuthor($author);
}
}
// Dealing with the main form, dealing with the Book entity
$bookForm = $this->createForm(BookType::class, $book);
$bookForm->handleRequest($request);
if ($bookForm->isSubmitted() && $bookForm->isValid()) {
$bookRepository->save($book, true);
return $this->redirectToRoute('bks_book_view', [
'ulid' => $book->getUlid(),
], Response::HTTP_SEE_OTHER);
}
return $this->renderForm('book/create.html.twig', [
'isbn_form' => $isbnForm,
'form' => $bookForm,
'book' => $book
]);
}
It works nicely until I tried to submit the thumbnail get from the Google Books API.
I've installed the VichUploaderBundle, set up a mapping, modified my Book entity accordingly to the documentation.
vich_uploader:
db_driver: orm
metadata:
type: attribute
mappings:
books:
uri_prefix: /uploads/books
upload_destination: '%kernel.project_dir%/public/uploads/books'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
inject_on_load: false
delete_on_update: true
delete_on_remove: true
<?php
namespace App\Entity;
use App\Repository\BookRepository;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Uid\Ulid;
use Vich\UploaderBundle\Mapping\Annotation\Uploadable;
use Vich\UploaderBundle\Mapping\Annotation\UploadableField;
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[Uploadable]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $subtitle = null;
#[ORM\ManyToMany(targetEntity: Author::class, inversedBy: 'books')]
private Collection $authors;
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
private ?Publisher $publisher = null;
#[ORM\Column(length: 4, nullable: true)]
private ?string $publication_date = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 13, nullable: true)]
private ?string $isbn = null;
#[ORM\Column(nullable: true)]
private ?int $pages = null;
#[ORM\Column(type: 'ulid')]
private ?Ulid $ulid = null;
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
private ?Bookshelf $bookshelf = null;
#[UploadableField(mapping: 'books', fileNameProperty: 'imageName', size: 'imageSize')]
private ?File $imageFile = null;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $imageName = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $imageSize = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $updatedAt = null;
public function __construct()
{
$this->authors = new ArrayCollection();
$this->updatedAt = new DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getSubtitle(): ?string
{
return $this->subtitle;
}
public function setSubtitle(?string $subtitle): self
{
$this->subtitle = $subtitle;
return $this;
}
/**
* #return Collection<int, Author>
*/
public function getAuthors(): Collection
{
return $this->authors;
}
public function addAuthor(Author $author): self
{
if (!$this->authors->contains($author)) {
$this->authors->add($author);
}
return $this;
}
public function removeAuthor(Author $author): self
{
$this->authors->removeElement($author);
return $this;
}
public function getPublisher(): ?Publisher
{
return $this->publisher;
}
public function setPublisher(?Publisher $publisher): self
{
$this->publisher = $publisher;
return $this;
}
public function getPublicationDate(): ?string
{
return $this->publication_date;
}
public function setPublicationDate(?string $publication_date): self
{
$this->publication_date = $publication_date;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getIsbn(): ?string
{
return $this->isbn;
}
public function setIsbn(?string $isbn): self
{
$this->isbn = $isbn;
return $this;
}
public function getPages(): ?int
{
return $this->pages;
}
public function setPages(?int $pages): self
{
$this->pages = $pages;
return $this;
}
public function getUlid(): ?Ulid
{
return $this->ulid;
}
#[ORM\PrePersist]
public function setUlid(): void
{
$this->ulid = new Ulid();
}
public function getBookshelf(): ?Bookshelf
{
return $this->bookshelf;
}
public function setBookshelf(?Bookshelf $bookshelf): self
{
$this->bookshelf = $bookshelf;
return $this;
}
/**
* If manually uploading a file (i.e. not using Symfony Form) ensure an instance
* of 'UploadedFile' is injected into this setter to trigger the update. If this
* bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
* must be able to accept an instance of 'File' as the bundle will inject one here
* during Doctrine hydration.
*
* #param File|\Symfony\Component\HttpFoundation\File\UploadedFile|null $imageFile
*/
public function setImageFile(?File $imageFile = null): void
{
$this->imageFile = $imageFile;
if (null !== $imageFile) {
// It is required that at least one field changes if you are using doctrine
// otherwise the event listeners won't be called and the file is lost
$this->updatedAt = new \DateTimeImmutable();
}
}
public function getImageFile(): ?File
{
return $this->imageFile;
}
public function setImageName(?string $imageName): void
{
$this->imageName = $imageName;
}
public function getImageName(): ?string
{
return $this->imageName;
}
public function setImageSize(?int $imageSize): void
{
$this->imageSize = $imageSize;
}
public function getImageSize(): ?int
{
return $this->imageSize;
}
}
Then I modify my BookController in order to add the imageFile to the Book entity :
#[Route('/create', name: 'create', methods: ['GET', 'POST'])]
public function new(
Request $request,
BookshelfRepository $bookshelfRepository,
AuthorRepository $authorRepository,
PublisherRepository $publisherRepository,
BookRepository $bookRepository
): Response {
$this->denyAccessUnlessGranted('edit', $this->getUser());
$book = new Book();
// Retrieving the Bookshelf passed along the request
// and associating it to the Book
if ($request->query->get('bksid')) {
$bookshelf = $bookshelfRepository->findOneBy(['ulid' => $request->query->get('bksid')]);
$book->setBookshelf($bookshelf);
}
// Dealing with the ISBN form
$isbnForm = $this->createForm(IsbnType::class);
$isbnForm->handleRequest($request);
if ($isbnForm->isSubmitted() && $isbnForm->isValid()) {
$isbnTools = new IsbnTools();
$isbn = $isbnTools->format($isbnForm->getData()['isbn']);
$book->setIsbn($isbn);
// Getting book's details from the Google Books API using the ISBN
$gbapi = new GoogleBooksApiUtils();
$details = $gbapi->gettingVolumeInfoByIsbn($isbn);
$book->setTitle($details->getTitle());
$book->setSubtitle($details->getSubtitle());
$book->setDescription($details->getDescription());
$book->setPages($details->getPageCount());
$book->setPublicationDate(substr($details->getPublishedDate(), 0, 4));
if ($details->getPublisher()) {
$publisher = $publisherRepository->findOneBy(['name' => $details->getPublisher()]);
if (!$publisher) {
$publisher = new Publisher();
$publisher->setName($details->getPublisher());
$publisherRepository->save($publisher, true);
}
$book->setPublisher($publisher);
}
foreach ($details->getAuthors() ?? [] as $dga) {
$author = $authorRepository->findOneBy(['name' => $dga]);
if (!$author) {
$author = new Author();
$author->setName($dga);
$authorRepository->save($author, true);
}
$book->addAuthor($author);
}
$thumb = $details->getImageLinks()->getThumbnail() ?? null;
if ($thumb) {
$path = 'uploads/books';
$filename = 'gbapi_' . $book->getIsbn() . '.jpg';
file_put_contents("$path/$filename", file_get_contents($thumb));
$file = new UploadedFile("$path/$filename", $filename, 'image/jpeg', null, false);
$book->setImageFile($file);
$book->setImageName($file->getFilename());
$book->setImageSize($file->getSize());
}
}
// Dealing with the main form, dealing with the Book entity
$bookForm = $this->createForm(BookType::class, $book);
$bookForm->handleRequest($request);
if ($bookForm->isSubmitted() && $bookForm->isValid()) {
$bookRepository->save($book, true);
return $this->redirectToRoute('bks_book_view', [
'ulid' => $book->getUlid(),
], Response::HTTP_SEE_OTHER);
}
return $this->renderForm('book/create.html.twig', [
'isbn_form' => $isbnForm,
'form' => $bookForm,
'book' => $book
]);
}
When I submit the $isbnForm, the data are well passed to the Book entity, and the image is displayed inside the $bookForm. But when I submit it, the imageFile (imageSize and imageName fields) are not persisted to the database.
I've tried to add the UploadedFile to the $request (something like that : $request->files->set('book', ['imageFile' => ['file' => $file]])) but without success...
I have been trying for two days to solve this issue and I'm not even sure I've identified clearly the issue...
Thanks in advance for any lead :-)

Symfony 5 easyadmin 3 Entity with relation ManyToOne - NOT saving on the "many" side

I have a very basic symfony 5 + easyadmin 3 app.
I created using the make:entity two entities: Posts and Categories
When I try to edit Category to assign Posts, posts are not saved in DB.
But If I add the category on the post edit is saves in db.
Any idea what I'm missing here?
CategoryCrudController.php
public function configureFields(string $pageName): iterable
{
if (Crud::PAGE_EDIT === $pageName)
{
yield TextField::new('title');
yield DateTimeField::new('created_at')
->setFormTypeOption('disabled','disabled');
yield AssociationField::new('posts')
->autocomplete();
Entity Category.php
/**
* #ORM\OneToMany(targetEntity=Post::class, mappedBy="category")
*/
private $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
}
/**
* #return Collection|Post[]
*/
public function getPosts(): Collection
{
return $this->posts;
}
public function addPost(Post $post): self
{
if (!$this->posts->contains($post)) {
$this->posts[] = $post;
$post->setCategory($this);
}
return $this;
}
public function removePost(Post $post): self
{
if ($this->posts->removeElement($post)) {
// set the owning side to null (unless already changed)
if ($post->getCategory() === $this) {
$post->setCategory(null);
}
}
return $this;
}
Found the solution thanks to:
https://github.com/EasyCorp/EasyAdminBundle/issues/860#issuecomment-192605475
For Easy Admin 3 you just need to add
->setFormTypeOptions([
'by_reference' => false,
])
CategoryCrudController.php
public function configureFields(string $pageName): iterable
{
if (Crud::PAGE_EDIT === $pageName)
{
yield TextField::new('title');
yield DateTimeField::new('created_at')
->setFormTypeOption('disabled','disabled');
yield AssociationField::new('posts')
->setFormTypeOptions([
'by_reference' => false,
])
->autocomplete();

Use UniqueEntity outside of entity and without forms

I need to validate an email passed by user:
private function validate($value): bool
{
$violations = $this->validator->validate($value, [
new Assert\NotBlank(),
new Assert\Email(),
new UniqueEntity([
'entityClass' => User::class,
'fields' => 'email',
])
]);
return count($violations) === 0;
}
But UniqueEntity constraint throws an exception:
Warning: get_class() expects parameter 1 to be object, string given
Seems like ValidatorInterface::validate() method's first argument awaiting for Entity object with getEmail() method, but it looks ugly.
Is there any elegant way to validate uniqueness of field passing only scalar value to ValidatorInterface::validate() method?
Seems like there is no built-in Symfony solution to do what I want, so I created custom constraint as Jakub Matczak suggested.
UPD: This solution throws a validation error when you're sending form to edit your entity. To avoid this behavior you'll need to improve this constraint manually.
Constraint:
namespace AppBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
class UniqueValueInEntity extends Constraint
{
public $message = 'This value is already used.';
public $entityClass;
public $field;
public function getRequiredOptions()
{
return ['entityClass', 'field'];
}
public function getTargets()
{
return self::PROPERTY_CONSTRAINT;
}
public function validatedBy()
{
return get_class($this).'Validator';
}
}
Validator:
namespace AppBundle\Validator\Constraints;
use Doctrine\ORM\EntityManager;
use InvalidArgumentException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class UniqueValueInEntityValidator extends ConstraintValidator
{
/**
* #var EntityManager
*/
private $em;
public function __construct(EntityManager $em)
{
$this->em = $em;
}
public function validate($value, Constraint $constraint)
{
$entityRepository = $this->em->getRepository($constraint->entityClass);
if (!is_scalar($constraint->field)) {
throw new InvalidArgumentException('"field" parameter should be any scalar type');
}
$searchResults = $entityRepository->findBy([
$constraint->field => $value
]);
if (count($searchResults) > 0) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}
Service:
services:
app.validator.unique_value_in_entity:
class: AppBundle\Validator\Constraints\UniqueValueInEntityValidator
arguments: ['#doctrine.orm.entity_manager']
tags:
- { name: validator.constraint_validator }
Usage example:
private function validate($value): bool
{
$violations = $this->validator->validate($value, [
new Assert\NotBlank(),
new Assert\Email(),
new UniqueValueInEntity([
'entityClass' => User::class,
'field' => 'email',
])
]);
return count($violations) === 0;
}
For this porpose i would use #UniqueEntity(fields={"email"}) in user class annotation. Kind of this way:
/**
* #ORM\Entity()
* #ORM\Table(name="user")
* #UniqueEntity(fields={"email"})
*/

Resources