phporocrm

ORO crm ManyToMany self reference association in core entity


Wanted to ask opinion on solution for current update. Few conditions and use case:

  1. Friends is extended manytomany property of core UserEntity from UserBundle
  2. When we add friend, owner entity should also appear in child entity
  3. When we remove friend, friends should also be un assinged from association with owning side

My solution is to use Event subscriber on onFlush even, maybe anyone has a better approach?

<?php

namespace App\Bundle\UserBundle\Form\EventListener;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Oro\Bundle\UserBundle\Entity\User;
use Doctrine\ORM\Event\OnFlushEventArgs;

class UserEventSubscriber implements EventSubscriber
{
    /**
     * @return array
     */
    public function getSubscribedEvents(): array
    {
        return [
            Events::onFlush,
        ];
    }

    /**
     * @param OnFlushEventArgs $args
     */
    public function onFlush(OnFlushEventArgs $args): void
    {
        $uow = $args->getEntityManager()->getUnitOfWork();

        $updates = array_merge(
            $uow->getScheduledCollectionUpdates(),
            $uow->getScheduledCollectionDeletions()
        );

        array_walk(
            $updates,
            fn (PersistentCollection $collection) => $this->processFriendsOnFlush($collection, $args)
        );
    }

    /**
     * @param PersistentCollection $collection
     * @param OnFlushEventArgs     $args
     */
    private function processFriendsOnFlush(PersistentCollection $collection, OnFlushEventArgs $args): void
    {
        $mapping = $collection->getMapping();

        if ($collection->getOwner() instanceof User && $mapping['fieldName'] === 'friends')
        {
            $entityManager = $args->getEntityManager();
            /** @var User $ownerUser */
            $ownerUser = $collection->getOwner();
            $snapshotFriends = $collection->getSnapshot();
            $updatedFriends = $collection->getValues();

            if (count($updatedFriends) > 0 && $snapshotFriends !== $updatedFriends) {
                array_walk($updatedFriends, fn(User $friend) => $this->bindFriendAssociation($entityManager, $ownerUser, $friend));
            }

            if (count($snapshotFriends) > 0) {
                array_walk($snapshotFriends, fn(User $friend) => $this->removeFriendAssociation($entityManager, $ownerUser, $friend));
            }
        }
    }

    /**
     * Bind friend association to keep bidirectional relation on adding new friend user
     *
     * @param EntityManager $entityManager
     * @param User          $ownerUser
     * @param User          $friend
     *
     * @throws \Doctrine\ORM\ORMException
     */
    private function bindFriendAssociation(EntityManager $entityManager, User $ownerUser, User $friend): void
    {
        if (!$friend->getFriends()->contains($ownerUser)) {
            $friend->addFriend($ownerUser);
            $entityManager->persist($friend);
            $entityManager->getUnitOfWork()->computeChangeSet($entityManager->getClassMetadata(get_class($friend)), $friend);
        }
    }

    /**
     * Remove friend user association to keep bidirectional relation on remove friend user from child
     *
     * @param EntityManager $entityManager
     * @param User          $ownerUser
     * @param User          $friend
     *
     * @throws \Doctrine\ORM\ORMException
     */
    private function removeFriendAssociation(EntityManager $entityManager, User $ownerUser, User $friend): void
    {
        if ($friend->getFriends()->contains($ownerUser)) {
            $friend->removeFriend($ownerUser);
            $entityManager->persist($friend);
            $entityManager->getUnitOfWork()->computeChangeSet($entityManager->getClassMetadata(get_class($friend)), $friend);
        }
    }
}


Same how I add ManyToMany association:

$this->extendExtension->addManyToManyRelation(
            $schema,
            $schema->getTable('oro_user'),
            'friends',
            $schema->getTable('oro_user'),
            ['last_name', 'first_name'],
            ['last_name', 'first_name'],
            ['last_name', 'first_name'],
            [
                'extend' => [
                    'owner' => ExtendScope::OWNER_CUSTOM,
                    'target_title' => ['id'],
                    'target_detailed' => ['id'],
                    'target_grid' => ['id'],
                    'cascade' => ['persist'],
                ],
                'dataaudit' => ['auditable' => true],
            ]
        );

Solution

  • Make sure the relation is bidirectional, and you have initialized the ArrayCollection for both properties in a entity constructor, as the documentation states: https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/reference/association-mapping.html#many-to-many-bidirectional.

    And you have add*, remove* methods with the proper implementation, like:

        public function addFriend(User $user)
        {
            if (!$this->friends->contains($user)) {
                $this->friends->add($user);
                $user->addFriend($this);
            }
    
            return $this;
        }
    
        public function removeFriend(User $user)
        {
            if ($this->friends->contains($user)) {
                $this->friends->removeElement($user);
                $user->removeFriend($this);
            }
    
            return $this;
        }
    

    If it's the code generated by OroPlatform, you can affect it by writing a custom Oro\Bundle\EntityExtendBundle\Tools\GeneratorExtensions\AbstractEntityGeneratorExtension. A good example is Oro\Bundle\EntitySerializedFieldsBundle\Tools\GeneratorExtensions\SerializedDataGeneratorExtension.

    For you, it would be something like:

    # ...
    public function generate(array $schema, ClassGenerator $class): void
    {
        $method = $class->getMethod('addFriend');
        $method->setBody('if (!$this->friends->contains($user)) {
            $this->friends->add($user);
            $user->addFriend($this);
        }');
    
        $method = $class->getMethod('removeFriend');
        $method->setBody('if ($this->friends->contains($user)) {
            $this->friends->removeElement($user);
            $user->removeFriend($this);
        }');
    }
    # ...
    

    And don't forget to register the generator in a service container with the oro_entity_extend.entity_generator_extension tag.