4

I have the following entity in Symfony :

class User implements AdvancedUserInterface, \Serializable {
    ...
    private $roles;
    ...

    public function __construct()
    {
        ...
        $this->roles = new ArrayCollection();
        // Default role for evey user (new entity);
        $this->roles->add("ROLE_USER");
        ...
    }

    ...

    function getRoles() {
        return $this->roles->toArray();
    }

    ...

    function addRole($role){
        $this->roles->add($role);
    }

    function removeRole($role){
        $this->roles->remove($role);
    }

    ...

    public function serialize()
    {
        return serialize(array(
            ...
            $this->roles,
            ...
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized)
    {
        list (
            ...
            $this->roles,
            ...
        ) = unserialize($serialized, ['allowed_classes' => false]);
    }
}

When I register one user, the default role (ROLE_USER) is added correctly. But when I try to edit one, the database recors does not change :

public function UserAddRole(Request $request){
    $userId = $request->request->get("userId");
    $role = "ROLE_" . strtoupper($request->request->get("role"));

    if($role == "ROLE_USER"){
        throw $this->createNotFoundException(
            'Cannot remove ROLE_USER role'
            );
    }

    $user = $this->getDoctrine()
        ->getRepository(User::class)
        ->findOneBy(array(
            'id' => $userId
        ));

    if (!$user) {
        throw $this->createNotFoundException(
            'User not found'
            );
    }

    $user->addRole($role);
    $entityManager = $this->getDoctrine()->getManager();
    $entityManager->persist($user);
    $entityManager->flush();

    return new Response("<pre>".var_dump($user->getRoles())."</pre>");
}

There are no constraints on this field, only hardcoded values for now.

The array returned in the response contains the roles I want, but not the database (checked by reloading the page and directly in MySQL.

Any idea ?

Thanks

JeanneD4RK
  • 323
  • 1
  • 5
  • 19

3 Answers3

0

I believe this problems comes from doctrine internal workings.

Start with this: Value object should be immutable. Remember this well, because it will help you understand how doctrine works.

So what happens is, you create a new value object(ArrayCollection) with ROLE_USER, which gets serialized and saved in the database.

When you fetch your entity, you will get back your value object. However, simply adding more items to the collection won't change it's hash, and that is what doctrine cares about.

Therefore your changes are not recognized.

This behavior is universal in doctrine, as far as value objects are concerned. Read it here: https://www.doctrine-project.org/projects/doctrine-orm/en/2.6/cookbook/working-with-datetime.html

Relations work well with collections, because they are prepared to handle this, as they don't work with object hashes alone. (In fact since doctrine 2.6 you should not swap out collections, hashes should stay the same. https://github.com/doctrine/doctrine2/pull/7219)

Fix: use a simple array to save roles, with the simple_array type.

OR: Creating the adder like this will trigger the save:

public function addRole(string $role)
{
    $this->roles->add($role);
    $this->roles = clone $this->roles; // this will trigger the db update, as it creates a new object hash.
}
Padam87
  • 1,013
  • 1
  • 6
  • 7
  • This means I will lose add and remove functionality right? Or I'll have to manage it manually I guess – JeanneD4RK Jul 30 '18 at 14:50
  • You could still keep the add / remove methods, just use an array. – Padam87 Jul 30 '18 at 15:27
  • Or if you are very fond of collections, you could clone the collection each time you add / remove a role... but it seems like overkill for me, plus having a comma separated list in the db is much nicer than a serialized object. – Padam87 Jul 30 '18 at 15:28
0

There are a few different things happening here. Per your comment above, you said you are mapping $roles to the array type. This is stored in the database by calling the native PHP functions serialize(...) and unserialize(...). That means that if an object had roles of ROLE_USER and ROLE_ADMIN, the data would look like this:

a:2:{i:0;s:9:"ROLE_USER";i:1;s:10:"ROLE_ADMIN";}

When Doctrine loads your object, it will use the internal PHP array type to store this data, meaning that $this->roles would have a runtime value of array('ROLE_USER', 'ROLE_ADMIN') in this example.

A similar type is simple_array, which behaves the same inside your application, but stores the value as a comma-delimited list in the database. So in this case, your database data would just be:

ROLE_USER,ROLE_ADMIN

Currently in your constructor, you are using the Doctrine ArrayCollection type to initialize $roles as a collection. However, if the field is mapped as array, after retrieving the object from the database, $roles will be a PHP array type, not an ArrayCollection object. To illustrate the difference:

// the constructor is called; $roles is an ArrayCollection
$user = new User();

// the constructor is not called; $roles is an array
$user = $entityManager->getRepository(User::class)->findOneById($userId);

Generally speaking, and actually in every case I've ever run into, you only want to initialize to ArrayCollection for association mappings, and use array or simple_array for scalar values, including the roles property.

You can still achieve your desired addRole(...) and removeRole(...) behavior by using a little bit of PHP. For example, using Doctrine annotation mapping:

use Doctrine\ORM\Mapping as ORM;

...

/**
 * @ORM\Column(name="roles", type="simple_array", nullable=false)
 */
private $roles;

...

/**
 * Add the given role to the set if it doesn't already exist.
 *
 * @param string $role
 */
public function addRole(string $role): void
{
    if (!in_array($role, $this->roles)) {
        $this->roles[] = $role;
    }
}

/**
 * Remove the given role from the set.
 *
 * @param string $role
 */
public function removeRole(string $role): void
{
    $this->roles = array_filter($this->roles, function ($value) use ($role) {
        return $value != $role;
    });
}

(Note that you will not be able to use type hinting unless you are using PHP 7 or above)

futureal
  • 3,025
  • 1
  • 22
  • 33
  • From what I could tell that was something the OP tried; the only difference between `object` and `array` is that the property will be loaded into a PHP object instead of a PHP array. The underlying storage is the same. In either case, it won't become a Doctrine `Collection` type. – futureal Jul 31 '18 at 00:43
  • It most definitely will. That is when value object immutability comes in. https://gist.github.com/Padam87/39fca58b6b9f6d8511a065974a1f4247 – Padam87 Jul 31 '18 at 01:15
  • Thanks, now what if I want to assign roles with a relation to another table ? I tried this morning, either returning an array of strings, which is useless, and tricky to add/remove, or throwing an error because returned object is not an insctance of Role... – JeanneD4RK Jul 31 '18 at 12:36
  • @JeanneD4RK array of strings with a `simple_array` mapping is by far the easiest, and probably the best way to do this. Do not overcoplicate this. – Padam87 Jul 31 '18 at 19:16
  • @Padam87 In that gist above, yes, if you serialize an _object_, it will return an object of that type when it unserializes it. But Doctrine does _not_ transform a `simple_array` mapping into a collection. If you really want to do that manually, you certainly can, but I see no reason to. Roles should always be managed as a simple array, and if you need something more complex, Symfony provides ways to hook into other authentication mechanisms. – futureal Aug 06 '18 at 03:13
  • I used a simple array. I was too lazy to implement myself add and remove functions but I did it. I was quicker than looking for the root of the problem. – JeanneD4RK Aug 07 '18 at 09:16
0

Change roles attribute in User entity accordingly and don't forget to update your schema:

public function __construct()
{
    $this->roles = new ArrayCollection();
    $this->addRole("ROLE_USER");
}

/**
 * @var ArrayCollection
 * @ORM\Column(name="roles", type="array", nullable=true)
 */
private $roles;

/**
 * Returns the roles granted to the user.
 *
 * @return (Role|string)[] The user roles
 */
public function getRoles()
{
    return $this->roles->toArray();
}


public function addRole($role)
{
    $this->roles->add($role);
    return $this;
}

public function removeRole($role)
{
    $this->roles->removeElement($role);
    $this->roles = clone $this->roles;
}