Search code examples
phpsymfonyvalidationsymfony-forms

Symfony2 data transformer, validator and error message


I asked this question and found out that we can't get the error message thrown by a DataTransformer (according to the only user who answered, maybe it's possible, I don't know).

Anyway, now that I know that, I am stucked with a problem of validation. Suppose my model is this one: I have threads that contains several participants (users).

<?php

class Thread
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\ManyToMany(targetEntity="My\UserBundle\Entity\User")
     * @ORM\JoinTable(name="messaging_thread_user")
     */
    private $participants;

    // other fields, getters, setters, etc
}

For thread creation, I want the user to specify the participants usernames in a textarea, separated by "\n". And I want that if one or more of the usernames specified don't exist, a message is displayed with the usernames that don't exist. For example, "Users titi, tata and toto don't exist".

For that I created a DataTransformer that transforms the raw text in the textarea into an ArrayCollection containing instances of users. Since I can't get the error message provided by this DataTransformer (such a shame! Is it really impossible?), I don't check the existence of each usernames in the DataTransformer but in the Validator.

Here is the DataTransformer that converts \n-separated user list into an ArrayCollection (so that the DataBinding is ok):

<?php

public function reverseTransform($val)
{
    if (empty($val)) {
        return null;
    }

    $return = new ArrayCollection();

    // Extract usernames in an array from the raw text
    $val = str_replace("\r\n", "\n", trim($val));
    $usernames = explode("\n", $val);
    array_map('trim', $usernames);

    foreach ($usernames as $username) {
        $user = new User();
        $user->setUsername($username);
        if (!$return->contains($user)) {
            $return->add($user);
        }
    }

    return $return;
}

And here is my validator:

<?php

public function isValid($value, Constraint $constraint)
{
    $repo = $this->em->getRepository('MyUserBundle:User');
    $notValidUsernames = array();

    foreach ($value as $user) {
        $username = $user->getUsername();
        if (!($user = $repo->findOneByUsername($username))) {
            $notValidUsernames[] = $username;
        }
    }

    if (count($notValidUsernames) == 0) {
        return true;
    }

    // At least one username is not ok here

    // Create the list of usernames separated by commas
    $list = '';
    $i = 1;

    foreach ($notValidUsernames as $username) {
        if ($i < count($notValidUsernames)) {
            $list .= $username;
            if ($i < count($notValidUsernames) - 1) {
                $list .= ', ';
            }
        }
        $i++;
    }

    $this->setMessage(
            $this->translator->transChoice(
                'form.error.participant_not_found',
                count($notValidUsernames),
                array(
                    '%usernames%' => $list,
                    '%last_username%' => end($notValidUsernames)
                )
            )
    );

    return false;
}

This current implementation looks ugly. I can see the error message well, but the users in the ArrayCollection returned by the DataTransformer are not synchronized with Doctrine.

I got two questions:

  • Is there any way that my validator could modify the value given in parameter? So that I can replace the simple User instances in the ArrayCollection returned by the DataTransformer into instances retrieved from the database?
  • Is there a simple and elegant way to do what I'm doing?

I guess the most simple way to do this is to be able to get the error message given by the DataTransformer. In the cookbook, they throw this exception: throw new TransformationFailedException(sprintf('An issue with number %s does not exist!', $val));, if I could put the list of non-existing usernames in the error message, it would be cool.

Thanks!


Solution

  • I am the one that answered your previous thread so maybe someone else will jump in here.

    Your code can be simplified considerably. You are only dealing with user names. No need for use objects or array collections.

    public function reverseTransform($val)
    {
        if (empty($val)) { return null; }
    
    
        // Extract usernames in an array from the raw text
        // $val = str_replace("\r\n", "\n", trim($val));
        $usernames = explode("\n", $val);
        array_map('trim', $usernames);
    
        // No real need to check for dups here
        return $usernames;
    }
    

    The validator:

    public function isValid($userNames, Constraint $constraint)
    {
        $repo = $this->em->getRepository('SkepinUserBundle:User');
        $notValidUsernames = array();
    
        foreach ($userNames as $userName) 
        {
          if (!($user = $repo->findOneByUsername($username))) 
            {
                $notValidUsernames[$userName] = $userName; // Takes care of dups
            }
        }
    
        if (count($notValidUsernames) == 0) {
            return true;
        }
    
        // At least one username is not ok here
        $invalidNames = implode(' ,',$notValidUsernames);
    
    
    $this->setMessage(
            $this->translator->transChoice(
                'form.error.participant_not_found',
                count($notValidUsernames),
                array(
                    '%usernames%' => $invalidNames,
                    '%last_username%' => end($notValidUsernames)
                )
            )
    );
    
        return false;
    }
    

    =========================================================================

    So at this point

    1. We have used transformer to copy the data from the text area and generated an array of user names during form->bind().

    2. We then used a validator to confirm that each user name actually exists in the database. If there are any that don't then we generate an error message and form->isValid() will fail.

    3. So now we are back in the controller, we know we have a list of valid user names (possibly comma delimited or possibly just an array). Now we want to add these to our thread object.

    One way would to create a thread manager service and add this functionality to it. So in the controller we might have:

    $threadManager = $this->get('thread.manager');
    $threadManager->addUsersToThread($thread,$users);
    

    For the thread manager we would inject our entity manager. In the add users method we would get a reference to each of the users, verify that the thread does not already have a link to this user, call $thread->addUser() and then flush.

    The fact that we have wrapped up this sort of functionality into a service class will make things easier to test as we can also make a command object and run this from the command line. it also gives us a nice spot to add additional thread related functionality. We might even consider injecting this manager into the user name validator and moving some of the isValid code to the manager.