OneToMany relationship issues - symfony

I have two entities, PartenairePermission and StructurePermission, I'm trying to get properties from the other entity in a One To Many relationship.
Which was working great with the one to one relationship but I modified it for a One To Many relationship and now I can't access the properties anymore
Attempted to call an undefined method named "setIsMembersRead" of class "Doctrine\ORM\PersistentCollection".
The idea of the script below is when the property is modified in PartenairePermission, it modified the property in StructurePermission too.
Any idea on how to solve this issue?
PartenaireController: [EDITED]
#[Route('/{id}/activate-permission', name: 'app_partenaire_activate-permission', methods: ['GET', 'POST'])]
public function activatePermission(EntityManagerInterface $entityManager, Request $request, PartenaireRepository $partenaireRepository, MailerInterface $mailer): Response
{
$partenairePermission = $entityManager->getRepository(PartenairePermission::class)->findOneBy([ // get the id of the partenaire
'id' => $request->get('id'),
]);
$partenairePermission->setIsMembersRead(!$partenairePermission->isIsMembersRead()); // set the value of the permission to the opposite of what it is ( for toggle switch )
$structurePermission = $partenairePermission->getPermissionStructure();
foreach ($structurePermission as $structurePermission) {
$structurePermission->setIsMembersRead($partenairePermission->isIsMembersRead());
}
$entityManager->persist($partenairePermission);
$entityManager->flush();
PartenairePermission.php :
#[ORM\OneToMany(mappedBy: 'permission_partenaire', targetEntity: StructurePermission::class, orphanRemoval: true)]
private Collection $permission_structure;
public function __construct()
{
$this->permission_structure = new ArrayCollection();
} /**
* #return Collection<int, StructurePermission>
*/
public function getPermissionStructure(): Collection
{
return $this->permission_structure;
}
public function addPermissionStructure(StructurePermission $permissionStructure): self
{
if (!$this->permission_structure->contains($permissionStructure)) {
$this->permission_structure->add($permissionStructure);
$permissionStructure->setPermissionPartenaire($this);
}
return $this;
}
public function removePermissionStructure(StructurePermission $permissionStructure): self
{
if ($this->permission_structure->removeElement($permissionStructure)) {
// set the owning side to null (unless already changed)
if ($permissionStructure->getPermissionPartenaire() === $this) {
$permissionStructure->setPermissionPartenaire(null);
}
}
return $this;
}
StructurePermission.php :
#[ORM\ManyToOne(fetch: "EAGER", inversedBy: 'permission_structure')]
#[ORM\JoinColumn(nullable: false)]
private ?PartenairePermission $permission_partenaire = null;
public function getPermissionPartenaire(): ?PartenairePermission
{
return $this->permission_partenaire;
}
public function setPermissionPartenaire(?PartenairePermission $permission_partenaire): self
{
$this->permission_partenaire = $permission_partenaire;
return $this;
}

Now you have to work different since you changed the association type:
$structurePermission = $partenairePermission->getPermissionStructure();
this will return a Collection (instead of a single Object as with your former One-to-One relationship).
and then something like:
foreach($structurePermission as $permission) {
// here you call your set/get/is for an Object within the Collection
}

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.

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();

Symfony 5 Save Object in MongoDB Collection

I have never worked with MongoDb and I am also new to symfony and ODM. I have a collection called userlist. In userlist I want to save multiple books. The book Id's get selected on a Form which I send to the server, in my controller I want to use all the selected BookIds use find($id) and save the returned Book details in my Userlist.
Document\Userlist.php
class Userlist{
/**
* #MongoDB\Id
*/
private $id;
/**
* #MongoDB\Field(type="string")
*/
private $name;
/**
* #MongoDB\Field(type="collection")
*/
private $books;
}
public function __construct()
{
$this->books = new ArrayCollection();
}
public function getBooks(): ?array
{
return array_unique($this->books);
}
public function setBooks(array $books): self
{
$this->books= $books;
return $this;
}
MyController.php
foreach ($_POST['books'] as $bookObjectId) {
$bookData= $dm->getRepository(Book::class)->find($bookObjectId);
$userlist->setBooks($bookData);
$dm->persist($userlist);
$dm->flush();
}
If I use above I get Collection type requires value of type array or null, Doctrine\Common\Collections\ArrayCollection given. So if I take $this->books = new ArrayCollection(); out this error disappears. Unfortunately all I get saved to the document is
books
[0]
No data is saved. If I use
$bookData= $dm->getRepository(Book::class)->find($bookObjectId);
$books = array('_id' => $bookData->getId(),
'name' =>$bookData->getBookName());
$userlist->setBooks($bookData);
$dm->persist($userlist);
$dm->flush();
Then the data will be all saved, but I would like to just get my 1 Document and save it straight into another document without re-creating an Array with calling all the getters and then save that. I have tried to read all over the internet, but I just don't get it. I also tried
public function addBooks(Book $books)
{
$this->books[] = $books;
return $this;
}
but again no luck. The Form by the way has got no BookId form field. Instead I create this dynamic with jQuery, but I doubt that this is a problem. Can anyone point in the right direction by any chance, this would be brilliant. Thank you very much.
UPDATE
My repository file:
public function findReturnArray($id)
{
return $this->createQueryBuilder()
->hydrate(false)
->field('_id')->equals($id)
->getQuery()
->execute()->toArray();
}
In the controller:
foreach ($_POST['bookIds'] as $orderObjectId) {
$bookData[] = $dm->getRepository(Book::class)->findReturnArray($bookObjectId);
}
$userlist->setBooks($bookData);
$dm->persist($userlist);
That does save both books into books but I have 1 issue. It is saved like this
books
[] [0]
{}[0]
bookId
bookName etc
[] [1]
{}[0]
bookId
bookName
what I would like is:
books
{}[0]
bookId
bookName etc
{}[1]
bookId
bookName
setBooks is still the same but I took the $this->books = new ArrayCollection(); out. any idea how I can stop this now?
If you want to persist references to Book documents in your Userlist document, you have to use the ReferenceMany annotation (see docs).
class Userlist
{
// ...
/**
* #MongoDB\ReferenceMany(targetDocument="My\Namespace\Book", storeAs="id")
*/
private $books;
public function __construct()
{
$this->books = new ArrayCollection();
}
public function getBooks(): array
{
return $this->books->getValues();
}
public function addBook(Book $book)
{
if ($this->books->contains($book)) {
return;
}
$this->books->add($book);
}
}
foreach ($_POST['books'] as $bookObjectId) {
$book = $dm->getRepository(Book::class)->find($bookObjectId);
$userlist->addBook($book);
}
$dm->persist($userlist);
$dm->flush();
UPDATE
After reading your update, I guess this is the change you are looking for:
public function findReturnArray($id)
{
return $this->createQueryBuilder()
->hydrate(false)
->field('_id')->equals($id)
->getQuery()
->getSingleResult();
}

How to hide item from collection depending on some field value?

I override (custom operation and service) the DELETE operation of my app to avoid deleting data from DB. What I do is I update a field value: isDeleted === true.
Here is my controller :
class ConferenceDeleteAction extends BaseAction
{
public function __invoke(EntityService $entityService, Conference $data)
{
$entityService->markAsDeleted($data, Conference::class);
}
...
My service :
class EntityService extends BaseService
{
public function markAsDeleted(ApiBaseEntity $data, string $className)
{
/**
* #var ApiBaseEntity $entity
*/
$entity = $this->em->getRepository($className)
->findOneBy(["id" => $data->getId()]);
if ($entity === null || $entity->getDeleted()) {
throw new NotFoundHttpException('Unable to find this resource.');
}
$entity->setDeleted(true);
if ($this->dataPersister->supports($entity)) {
$this->dataPersister->persist($entity);
} else {
throw new BadRequestHttpException('An error occurs. Please do try later.');
}
}
}
How can I hide the "deleted" items from collection on GET verb (filter them from the result so that they aren't visible) ?
Here is my operation for GET verb, I don't know how to handle this :
class ConferenceListAction extends BaseAction
{
public function __invoke(Request $request, $data)
{
return $data;
}
}
I did something; I'm not sure it's a best pratice.
Since when we do :
return $data;
in our controller, API Platform has already fetch data and fill $data with.
So I decided to add my logic before the return; like :
public function __invoke(Request $request, $data)
{
$cleanDatas = [];
/**
* #var Conference $conf
*/
foreach ($data as $conf) {
if (!$conf->getDeleted()) {
$cleanDatas[] = $conf;
}
}
return $cleanDatas;
}
So now I only have undeleted items. Feel free to let me know if there is something better.
Thanks.
Custom controllers are discouraged in the docs. You are using Doctrine ORM so you can use a Custom Doctrine ORM Extension:
// api/src/Doctrine/ConferenceCollectionExtension.php
namespace App\Doctrine;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Conference;
use Doctrine\ORM\QueryBuilder;
final class CarCollectionExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
{
if ($resourceClass != Conference::class) return;
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("$rootAlias.isDeleted = false OR $rootAlias.isDeleted IS NULL);
}
}
This will automatically be combined with any filters, sorting and pagination of collection operations with method GET.
You can make this Extension specific to an operation by adding to the if statement something like:
|| $operationName == 'conference_list'
If you're not using the autoconfiguration, you have to register the custom extension:
# api/config/services.yaml
services:
# ...
'App\Doctrine\ConferenceCollectionExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
If you also want to add a criterium for item operations, see the docs on Extensions

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