Only return collection when field is false - symfony

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.

Related

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.

#[ApiResource] attribute causing exception in OneToMay resource

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\""

ManyToMany new value must be an array or an instance of \Traversable, "NULL" given

I have a ManyToMany relation in my Symfony 4.2.6 application and I would like for it to be possible to have this to be null.
So my first entity SpecialOffers is as follows :
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* #ORM\Entity(repositoryClass="App\Repository\SpecialOfferRepository")
*/
class SpecialOffer
{
/**
* #ORM\ManyToMany(targetEntity="App\Entity\Neighbourhood", inversedBy="specialOffers")
*/
private $neighbourhood;
public function __construct()
{
$this->neighbourhood = new ArrayCollection();
}
/**
* #return Collection|Neighbourhood[]
*/
public function getNeighbourhood(): Collection
{
return $this->neighbourhood;
}
public function addNeighbourhood(Neighbourhood $neighbourhood): self
{
if (!$this->neighbourhood->contains($neighbourhood)) {
$this->neighbourhood[] = $neighbourhood;
}
return $this;
}
public function removeNeighbourhood(Neighbourhood $neighbourhood): self
{
if ($this->neighbourhood->contains($neighbourhood)) {
$this->neighbourhood->removeElement($neighbourhood);
}
return $this;
}
}
It is related to the neighbourhood class :
/**
* #ORM\Entity(repositoryClass="App\Repository\NeighbourhoodRepository")
*/
class Neighbourhood implements ResourceInterface
{
/**
* #ORM\ManyToMany(targetEntity="App\Entity\SpecialOffer", mappedBy="neighbourhood")
*/
private $specialOffers;
public function __construct()
{
$this->specialOffers = new ArrayCollection();
}
/**
* #return Collection|SpecialOffer[]
*/
public function getSpecialOffers(): Collection
{
return $this->specialOffers;
}
public function addSpecialOffer(SpecialOffer $specialOffer): self
{
if (!$this->specialOffers->contains($specialOffer)) {
$this->specialOffers[] = $specialOffer;
$specialOffer->addNeighbourhood($this);
}
return $this;
}
public function removeSpecialOffer(SpecialOffer $specialOffer): self
{
if ($this->specialOffers->contains($specialOffer)) {
$this->specialOffers->removeElement($specialOffer);
$specialOffer->removeNeighbourhood($this);
}
return $this;
}
}
And finally the form is
class SpecialOfferType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'neighbourhood',
EntityType::class,
[
'class' => Neighbourhood::class,
'label' => 'form.neighbourhood.label',
'translation_domain' => 'Default',
'required' => false,
'placeholder' => 'form.neighbourhood.all'
]
);
}
}
But when I don't select a specific neighbourhood for the Special offer in my form I get the following error :
Could not determine access type for property "neighbourhood" in class "App\Entity\SpecialOffer": The property "neighbourhood" in class "App\Entity\SpecialOffer" can be defined with the methods "addNeighbourhood()", "removeNeighbourhood()" but the new value must be an array or an instance of \Traversable, "NULL" given.
Is there anyway I can make it so that my special offer either contains and array of neighbourhoods or just null ?
I feel like I'm overlooking something really obvious, any help would be greatly appreciated
Test =>
$builder
->add(
'neighbourhood',
EntityType::class,
[
'class' => Neighbourhood::class,
'label' => 'form.neighbourhood.label',
'translation_domain' => 'Default',
'required' => false,
'multiple' => true,
'placeholder' => 'form.neighbourhood.all'
]
);
Since your fields on the entities are both many-to-many, thus expecting an array (or similar) and the form field is of EntityType, which will return one Entity of the expected type or null, I feel like there is some form of asymmetry.
I would consider using the CollectionType from the start or at least setting the multiple option on the form to true, so that the return value is an array.
Another option would be to add a DataTransformer to the form field, which turns null into an empty array and one entity into an array of one entity, and vice-versa.

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"})
*/

Symfony Form - Allow removal of nested form associated entity

I have a /checkout JSON API endpoint which allows an optional billingAddress parameter alongside other parameters such as email and deliveryAddress.
These addresses are stored in an Address entity related to an Order entity.
Everything works nicely if a user enters their billingAddress, but if a user removes a previously submitted billing address, I can find no way to remove the billingAddress entity. Ideally to remove the billing address I'd use the following JSON POST request.
{
"email": "nick#example.com",
"deliveryAddress": {
"line1": "1 Box Lane"
},
"billingAddress": null
}
Is this at all possible with Symfony forms?
See below for a simplified explanation of the current setup.
Entities
/**
* #ORM\Entity
*/
class Order
{
// ...
/**
* #var Address
*
* #ORM\OneToOne(targetEntity = "Address", cascade = {"persist", "remove"})
* #ORM\JoinColumn(name = "deliveryAddressId", referencedColumnName = "addressId")
*/
private $deliveryAddress;
/**
* #var Address
*
* #ORM\OneToOne(targetEntity = "Address", cascade = {"persist", "remove"}, orphanRemoval = true)
* #ORM\JoinColumn(name = "billingAddressId", referencedColumnName = "addressId", nullable = true)
*/
private $billingAddress;
public function setDeliveryAddress(Address $deliveryAddress = null)
{
$this->deliveryAddress = $deliveryAddress;
return $this;
}
public function getDeliveryAddress()
{
return $this->deliveryAddress;
}
public function setBillingAddress(Address $billingAddress = null)
{
$this->billingAddress = $billingAddress;
return $this;
}
public function getBillingAddress()
{
return $this->billingAddress;
}
// ...
}
.
/**
* #ORM\Entity
*/
class Address
{
// ...
/**
* #var string
*
* #ORM\Column(type = "string", length = 45, nullable = true)
*/
private $line1;
// ...
}
Forms
class CheckoutType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class)
->add('deliveryAddress', AddressType::class, [
'required' => true
])
->add('billingAddress', AddressType::class, [
'required' => false
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Order::class,
'csrf_protection' => false,
'allow_extra_fields' => true,
'cascade_validation' => true
]);
}
public function getBlockPrefix()
{
return '';
}
}
.
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
// ...
->add('line1', TextType::class);
// ...
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Address::class,
'allow_extra_fields' => true
]);
}
public function getBlockPrefix()
{
return '';
}
}
Form events are what you need: https://symfony.com/doc/current/form/events.html
For example if you want to remove the billingAddress field after the form submission you can do that:
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class)
->add('deliveryAddress', AddressType::class, [
'required' => true
])
->add('billingAddress', AddressType::class, [
'required' => false
]);
$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) {
$form = $event->getForm();
$data = $event->getData();
if (empty($data['billingAddress'])) {
$form->remove('billingAddress');
}
});
}
Read carefully the documentation to know which event will be the best for your scenario.
Try setting the "by_reference" option for the "billingAddress" field to false in order to make sure that the setter is called.
http://symfony.com/doc/current/reference/forms/types/form.html#by-reference
Big thanks to Renan and Raphael's answers as they led me to discovering the below solution which works for both a partial PATCH and full POST request.
class CheckoutType extends AbstractType
{
/** #var bool */
private $removeBilling = false;
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class)
->add('deliveryAddress', AddressType::class, [
'constraints' => [new Valid]
])
->add('billingAddress', AddressType::class, [
'required' => false,
'constraints' => [new Valid]
])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit'])
->addEventListener(FormEvents::POST_SUBMIT, [$this, 'onPostSubmit']);
}
public function onPreSubmit(FormEvent $event)
{
$data = $event->getData();
$this->removeBilling = array_key_exists('billingAddress', $data) && is_null($data['billingAddress']);
}
public function onPostSubmit(FormEvent $event)
{
if ($this->removeBilling) {
$event->getData()->setBillingAddress(null);
}
}
}

Resources