Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Symfony2 collection of Entities - how to add/remove association with existing entities?

1. Quick overview

1.1 Goal

What I'm trying to achieve is a create/edit user tool. Editable fields are:

  • username (type: text)
  • plainPassword (type: password)
  • email (type: email)
  • groups (type: collection)
  • avoRoles (type: collection)

Note: the last property is not named $roles becouse my User class is extending FOSUserBundle's User class and overwriting roles brought more problems. To avoid them I simply decided to store my collection of roles under $avoRoles.

1.2 User Interface

My template consists of 2 sections:

  1. User form
  2. Table displaying $userRepository->findAllRolesExceptOwnedByUser($user);

Note: findAllRolesExceptOwnedByUser() is a custom repository function, returns a subset of all roles (those not yet assigned to $user).

1.3 Desired functionality

1.3.1 Add role:


    WHEN user clicks "+" (add) button in Roles table  
    THEN jquery removes that row from Roles table  
    AND  jquery adds new list item to User form (avoRoles list)

1.3.2 Remove roles:


    WHEN user clicks "x" (remove) button in  User form (avoRoles list)  
    THEN jquery removes that list item from User form (avoRoles list)  
    AND  jquery adds new row to Roles table

1.3.3 Save changes:


    WHEN user clicks "Zapisz" (save) button  
    THEN user form submits all fields (username, password, email, avoRoles, groups)  
    AND  saves avoRoles as an ArrayCollection of Role entities (ManyToMany relation)  
    AND  saves groups as an ArrayCollection of Role entities (ManyToMany relation)  

Note: ONLY existing Roles and Groups can be assigned to User. If for any reason they are not found the form should not validate.


2. Code

In this section I present/or shortly describe code behind this action. If description is not enough and you need to see the code just tell me and I'll paste it. I'm not pasteing it all in the first place to avoid spamming you with unnecessary code.

2.1 User class

My User class extends FOSUserBundle user class.

namespace Avocode\UserBundle\Entity;

use FOS\UserBundle\Entity\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Avocode\CommonBundle\Collections\ArrayCollection;
use Symfony\Component\Validator\ExecutionContext;

/**
 * @ORM\Entity(repositoryClass="Avocode\UserBundle\Repository\UserRepository")
 * @ORM\Table(name="avo_user")
 */
class User extends BaseUser
{
    const ROLE_DEFAULT = 'ROLE_USER';
    const ROLE_SUPER_ADMIN = 'ROLE_SUPER_ADMIN';

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\generatedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="Group")
     * @ORM\JoinTable(name="avo_user_avo_group",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
     * )
     */
    protected $groups;

    /**
     * @ORM\ManyToMany(targetEntity="Role")
     * @ORM\JoinTable(name="avo_user_avo_role",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="role_id", referencedColumnName="id")}
     * )
     */
    protected $avoRoles;

    /**
     * @ORM\Column(type="datetime", name="created_at")
     */
    protected $createdAt;

    /**
     * User class constructor
     */
    public function __construct()
    {
        parent::__construct();

        $this->groups = new ArrayCollection();        
        $this->avoRoles = new ArrayCollection();
        $this->createdAt = new \DateTime();
    }

    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set user roles
     * 
     * @return User
     */
    public function setAvoRoles($avoRoles)
    {
        $this->getAvoRoles()->clear();

        foreach($avoRoles as $role) {
            $this->addAvoRole($role);
        }

        return $this;
    }

    /**
     * Add avoRole
     *
     * @param Role $avoRole
     * @return User
     */
    public function addAvoRole(Role $avoRole)
    {
        if(!$this->getAvoRoles()->contains($avoRole)) {
          $this->getAvoRoles()->add($avoRole);
        }

        return $this;
    }

    /**
     * Get avoRoles
     *
     * @return ArrayCollection
     */
    public function getAvoRoles()
    {
        return $this->avoRoles;
    }

    /**
     * Set user groups
     * 
     * @return User
     */
    public function setGroups($groups)
    {
        $this->getGroups()->clear();

        foreach($groups as $group) {
            $this->addGroup($group);
        }

        return $this;
    }

    /**
     * Get groups granted to the user.
     *
     * @return Collection
     */
    public function getGroups()
    {
        return $this->groups ?: $this->groups = new ArrayCollection();
    }

    /**
     * Get user creation date
     *
     * @return DateTime
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }
}

2.2 Role class

My Role class extends Symfony Security Component Core Role class.

namespace Avocode\UserBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Avocode\CommonBundle\Collections\ArrayCollection;
use Symfony\Component\Security\Core\Role\Role as BaseRole;

/**
 * @ORM\Entity(repositoryClass="Avocode\UserBundle\Repository\RoleRepository")
 * @ORM\Table(name="avo_role")
 */
class Role extends BaseRole
{    
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\generatedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string", unique="TRUE", length=255)
     */
    protected $name;

    /**
     * @ORM\Column(type="string", length=255)
     */
    protected $module;

    /**
     * @ORM\Column(type="text")
     */
    protected $description;

    /**
     * Role class constructor
     */
    public function __construct()
    {
    }

    /**
     * Returns role name.
     * 
     * @return string
     */    
    public function __toString()
    {
        return (string) $this->getName();
    }

    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set name
     *
     * @param string $name
     * @return Role
     */
    public function setName($name)
    {      
        $name = strtoupper($name);
        $this->name = $name;

        return $this;
    }

    /**
     * Get name
     *
     * @return string 
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Set module
     *
     * @param string $module
     * @return Role
     */
    public function setModule($module)
    {
        $this->module = $module;

        return $this;
    }

    /**
     * Get module
     *
     * @return string 
     */
    public function getModule()
    {
        return $this->module;
    }

    /**
     * Set description
     *
     * @param text $description
     * @return Role
     */
    public function setDescription($description)
    {
        $this->description = $description;

        return $this;
    }

    /**
     * Get description
     *
     * @return text 
     */
    public function getDescription()
    {
        return $this->description;
    }
}

2.3 Groups class

Since I've got the same problem with groups as with roles, I'm skipping them here. If I get roles working I know I can do the same with groups.

2.4 Controller

namespace Avocode\UserBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Security\Core\SecurityContext;
use JMS\SecurityExtraBundle\Annotation\Secure;
use Avocode\UserBundle\Entity\User;
use Avocode\UserBundle\Form\Type\UserType;

class UserManagementController extends Controller
{
    /**
     * User create
     * @Secure(roles="ROLE_USER_ADMIN")
     */
    public function createAction(Request $request)
    {      
        $em = $this->getDoctrine()->getEntityManager();

        $user = new User();
        $form = $this->createForm(new UserType(array('password' => true)), $user);

        $roles = $em->getRepository('AvocodeUserBundle:User')
                    ->findAllRolesExceptOwned($user);
        $groups = $em->getRepository('AvocodeUserBundle:User')
                    ->findAllGroupsExceptOwned($user);

        if($request->getMethod() == 'POST' && $request->request->has('save')) {
            $form->bindRequest($request);

            if($form->isValid()) {
                /* Persist, flush and redirect */
                $em->persist($user);
                $em->flush();
                $this->setFlash('avocode_user_success', 'user.flash.user_created');
                $url = $this->container->get('router')->generate('avocode_user_show', array('id' => $user->getId()));

                return new RedirectResponse($url);
            }
        }

        return $this->render('AvocodeUserBundle:UserManagement:create.html.twig', array(
          'form' => $form->createView(),
          'user' => $user,
          'roles' => $roles,
          'groups' => $groups,
        ));
    }
}

2.5 Custom repositories

It is not neccesary to post this since they work just fine - they return a subset of all Roles/Groups (those not assigned to user).

2.6 UserType

UserType:

namespace Avocode\UserBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class UserType extends AbstractType
{    
    private $options; 

    public function __construct(array $options = null) 
    { 
        $this->options = $options; 
    }

    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder->add('username', 'text');

        // password field should be rendered only for CREATE action
        // the same form type will be used for EDIT action
        // thats why its optional

        if($this->options['password'])
        {
          $builder->add('plainpassword', 'repeated', array(
                        'type' => 'text',
                        'options' => array(
                          'attr' => array(
                            'autocomplete' => 'off'
                          ),
                        ),
                        'first_name' => 'input',
                        'second_name' => 'confirm', 
                        'invalid_message' => 'repeated.invalid.password',
                     ));
        }

        $builder->add('email', 'email', array(
                        'trim' => true,
                     ))

        // collection_list is a custom field type
        // extending collection field type
        //
        // the only change is diffrent form name
        // (and a custom collection_list_widget)
        // 
        // in short: it's a collection field with custom form_theme
        // 
                ->add('groups', 'collection_list', array(
                        'type' => new GroupNameType(),
                        'allow_add' => true,
                        'allow_delete' => true,
                        'by_reference' => true,
                        'error_bubbling' => false,
                        'prototype' => true,
                     ))
                ->add('avoRoles', 'collection_list', array(
                        'type' => new RoleNameType(),
                        'allow_add' => true,
                        'allow_delete' => true,
                        'by_reference' => true,
                        'error_bubbling' => false,
                        'prototype' => true,
                     ));
    }

    public function getName()
    {
        return 'avo_user';
    }

    public function getDefaultOptions(array $options){

        $options = array(
          'data_class' => 'Avocode\UserBundle\Entity\User',
        );

        // adding password validation if password field was rendered

        if($this->options['password'])
          $options['validation_groups'][] = 'password';

        return $options;
    }
}

2.7 RoleNameType

This form is supposed to render:

  • hidden Role ID
  • Role name (READ ONLY)
  • hidden module (READ ONLY)
  • hidden description (READ ONLY)
  • remove (x) button

Module and description are rendered as hidden fields, becouse when Admin removes a role from a User, that role should be added by jQuery to Roles Table - and this table has Module and Description columns.

namespace Avocode\UserBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class RoleNameType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder            
            ->add('', 'button', array(
              'required' => false,
            ))  // custom field type rendering the "x" button

            ->add('id', 'hidden')

            ->add('name', 'label', array(
              'required' => false,
            )) // custom field type rendering <span> item instead of <input> item

            ->add('module', 'hidden', array('read_only' => true))
            ->add('description', 'hidden', array('read_only' => true))
        ;        
    }

    public function getName()
    {
        // no_label is a custom widget that renders field_row without the label

        return 'no_label';
    }

    public function getDefaultOptions(array $options){
        return array('data_class' => 'Avocode\UserBundle\Entity\Role');
    }
}

3. Current/known Problems

3.1 Case 1: configuration as quoted above

The above configuration returns error:

Property "id" is not public in class "Avocode\UserBundle\Entity\Role". Maybe you should create the method "setId()"?

But setter for ID should not be required.

  1. First becouse I don't want to create a NEW role. I want just to create a relation between existing Role and User entities.
  2. Even if I did want to create a new Role, it's ID should be auto-generated:

    /**

    • @ORM\Id
    • @ORM\Column(type="integer")
    • @ORM\generatedValue(strategy="AUTO") */ protected $id;

3.2 Case 2: added setter for ID property in Role entity

I think it's wrong, but I did it just to be sure. After adding this code to Role entity:

public function setId($id)
{
    $this->id = $id;
    return $this;
}

If I create new user and add a role, then SAVE... What happens is:

  1. New user is created
  2. New user has role with the desired ID assigned (yay!)
  3. but that role's name is overwritten with empty string (bummer!)

Obviously, thats not what I want. I don't want to edit/overwrite roles. I just want to add a relation between them and the User.

3.3 Case 3: Workaround suggested by Jeppe

When I first encountered this problem I ended up with a workaround, the same that Jeppe suggested. Today (for other reasons) I had to remake my form/view and the workaround stopped working.

What changes in Case3 UserManagementController -> createAction:

  // in createAction
  // instead of $user = new User
  $user = $this->updateUser($request, new User());

  //and below updateUser function


    /**
     * Creates mew iser and sets its properties
     * based on request
     * 
     * @return User Returns configured user
     */
    protected function updateUser($request, $user)
    {
        if($request->getMethod() == 'POST')
        {
          $avo_user = $request->request->get('avo_user');

          /**
           * Setting and adding/removeing groups for user
           */
          $owned_groups = (array_key_exists('groups', $avo_user)) ? $avo_user['groups'] : array();
          foreach($owned_groups as $key => $group) {
            $owned_groups[$key] = $group['id'];
          }

          if(count($owned_groups) > 0)
          {
            $em = $this->getDoctrine()->getEntityManager();
            $groups = $em->getRepository('AvocodeUserBundle:Group')->findById($owned_groups);
            $user->setGroups($groups);
          }

          /**
           * Setting and adding/removeing roles for user
           */
          $owned_roles = (array_key_exists('avoRoles', $avo_user)) ? $avo_user['avoRoles'] : array();
          foreach($owned_roles as $key => $role) {
            $owned_roles[$key] = $role['id'];
          }

          if(count($owned_roles) > 0)
          {
            $em = $this->getDoctrine()->getEntityManager();
            $roles = $em->getRepository('AvocodeUserBundle:Role')->findById($owned_roles);
            $user->setAvoRoles($roles);
          }

          /**
           * Setting other properties
           */
          $user->setUsername($avo_user['username']);
          $user->setEmail($avo_user['email']);

          if($request->request->has('generate_password'))
            $user->setPlainPassword($user->generateRandomPassword());  
        }

        return $user;
    }

Unfortunately this does not change anything.. the results are either CASE1 (with no ID setter) or CASE2 (with ID setter).

3.4 Case 4: as suggested by userfriendly

Adding cascade={"persist", "remove"} to mapping.

/**
 * @ORM\ManyToMany(targetEntity="Group", cascade={"persist", "remove"})
 * @ORM\JoinTable(name="avo_user_avo_group",
 *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
 *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
 * )
 */
protected $groups;

/**
 * @ORM\ManyToMany(targetEntity="Role", cascade={"persist", "remove"})
 * @ORM\JoinTable(name="avo_user_avo_role",
 *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
 *      inverseJoinColumns={@ORM\JoinColumn(name="role_id", referencedColumnName="id")}
 * )
 */
protected $avoRoles;

And changeing by_reference to false in FormType:

// ...

                ->add('avoRoles', 'collection_list', array(
                        'type' => new RoleNameType(),
                        'allow_add' => true,
                        'allow_delete' => true,
                        'by_reference' => false,
                        'error_bubbling' => false,
                        'prototype' => true,
                     ));

// ...

And keeping workaround code suggested in 3.3 did change something:

  1. Association between user and role was not created
  2. .. but Role entity's name was overwritten by empty string (like in 3.2)

So.. it did change something but in the wrong direction.

4. Versions

4.1 Symfony2 v2.0.15

4.2 Doctrine2 v2.1.7

4.3 FOSUserBundle version: 6fb81861d84d460f1d070ceb8ec180aac841f7fa

5. Summary

I've tried many diffrent approaches (above are only the most recent ones) and after hours spent on studying code, google'ing and looking for the answer I just couldn't get this working.

Any help will be greatly appreciated. If you need to know anything I'll post whatever part of code you need.

like image 507
ioleo Avatar asked Jun 18 '12 19:06

ioleo


2 Answers

I've come to the same conclusion that there's something wrong with the Form component and can't see an easy way to fix it. However, I've come up with a slightly less cumbersome workaround solution that is completely generic; it doesn't have any hard-coded knowledge of entities/attributes so will fix any collection it comes across:

Simpler, generic workaround method

This doesn't require you to make any changes to your entity.

use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\Form;

# In your controller. Or possibly defined within a service if used in many controllers

/**
 * Ensure that any removed items collections actually get removed
 *
 * @param \Symfony\Component\Form\Form $form
 */
protected function cleanupCollections(Form $form)
{
    $children = $form->getChildren();

    foreach ($children as $childForm) {
        $data = $childForm->getData();
        if ($data instanceof Collection) {

            // Get the child form objects and compare the data of each child against the object's current collection
            $proxies = $childForm->getChildren();
            foreach ($proxies as $proxy) {
                $entity = $proxy->getData();
                if (!$data->contains($entity)) {

                    // Entity has been removed from the collection
                    // DELETE THE ENTITY HERE

                    // e.g. doctrine:
                    // $em = $this->getDoctrine()->getEntityManager();
                    // $em->remove($entity);

                }
            }
        }
    }
}

Call the new cleanupCollections() method before persisting

# in your controller action...

if($request->getMethod() == 'POST') {
    $form->bindRequest($request);
    if($form->isValid()) {

        // 'Clean' all collections within the form before persisting
        $this->cleanupCollections($form);

        $em->persist($user);
        $em->flush();

        // further actions. return response...
    }
}
like image 124
RobMasters Avatar answered Nov 03 '22 06:11

RobMasters


1. The workaround solution

The workaround solution suggested by Jeppe Marianger-Lam is at the moment the only one working I know of.

1.1 Why did it stop working in my case?

I changed my RoleNameType (for other reasons) to:

  • ID (hidden)
  • name (custom type - label)
  • module & description (hidden, read-only)

The problem was my custom type label rendered NAME property as


    <span> role name </span>

And since it was not "read only" the FORM component expected to get NAME in POST.

Instead only ID was POSTed, and thus FORM component assumed NAME is NULL.

This lead to CASE 2 (3.2) -> creating association, but overwriting ROLE NAME with an empty string.

2. So, what exacly is this workaround about?

2.1 Controller

This workaround is very simple.

In your controller, before you VALIDATE the form, you have to fetch the posted entity identyficators and get matching entities, then set them to your object.

// example action
public function createAction(Request $request)
{      
    $em = $this->getDoctrine()->getEntityManager();

    // the workaround code is in updateUser function
    $user = $this->updateUser($request, new User());

    $form = $this->createForm(new UserType(), $user);

    if($request->getMethod() == 'POST') {
        $form->bindRequest($request);

        if($form->isValid()) {
            /* Persist, flush and redirect */
            $em->persist($user);
            $em->flush();
            $this->setFlash('avocode_user_success', 'user.flash.user_created');
            $url = $this->container->get('router')->generate('avocode_user_show', array('id' => $user->getId()));

            return new RedirectResponse($url);
        }
    }

    return $this->render('AvocodeUserBundle:UserManagement:create.html.twig', array(
      'form' => $form->createView(),
      'user' => $user,
    ));
}

And below the workaround code in updateUser function:

protected function updateUser($request, $user)
{
    if($request->getMethod() == 'POST')
    {
      // getting POSTed values
      $avo_user = $request->request->get('avo_user');

      // if no roles are posted, then $owned_roles should be an empty array (to avoid errors)
      $owned_roles = (array_key_exists('avoRoles', $avo_user)) ? $avo_user['avoRoles'] : array();

      // foreach posted ROLE, get it's ID
      foreach($owned_roles as $key => $role) {
        $owned_roles[$key] = $role['id'];
      }

      // FIND all roles with matching ID's
      if(count($owned_roles) > 0)
      {
        $em = $this->getDoctrine()->getEntityManager();
        $roles = $em->getRepository('AvocodeUserBundle:Role')->findById($owned_roles);

        // and create association
        $user->setAvoRoles($roles);
      }

    return $user;
}

For this to work your SETTER (in this case in User.php entity) must be:

public function setAvoRoles($avoRoles)
{
    // first - clearing all associations
    // this way if entity was not found in POST
    // then association will be removed

    $this->getAvoRoles()->clear();

    // adding association only for POSTed entities
    foreach($avoRoles as $role) {
        $this->addAvoRole($role);
    }

    return $this;
}

3. Final thoughts

Still, I think this workaround is doing the job that

$form->bindRequest($request);

should do! It's either me doing something wrong, or symfony's Collection form type is not complete.

There are some major changes in Form component comeing in symfony 2.1, hopefully this will be fixed.

PS. If it's me doing something wrong...

... please post the way it should be done! I'd be glad to see a quick, easy and "clean" solution.

PS2. Special thanks to:

Jeppe Marianger-Lam and userfriendly (from #symfony2 on IRC). You've been very helpful. Cheers!

like image 41
ioleo Avatar answered Nov 03 '22 05:11

ioleo