Buy

We've got more work to do! So head back to /admin/genus. Leave the "Years Studied" field empty for one of the GenusScientist forms and submit.

Explosion!

UPDATE genus_scientist SET years_studied = NULL

This field is not allowed to be null in the database. That's on purpose... but we're missing validation! Lame!

But no problem, right? We'll just go into the Genus class, copy the as Assert use statement, paste it into GenusScientist and then - above yearsStudied - add @Assert\NotBlank:

73 lines src/AppBundle/Entity/GenusScientist.php
... lines 1 - 5
use Symfony\Component\Validator\Constraints as Assert;
... lines 7 - 11
class GenusScientist
{
... lines 14 - 32
/**
... line 34
* @Assert\NotBlank()
*/
private $yearsStudied;
... lines 38 - 72
}

Cool! Now, the yearsStudied field will be required.

Go try it out: refresh the page, empty out the field again, submit and... What!? It still doesn't work!?

@Valid for a Good Time

It's as if Symfony doesn't see the new validation constraint! Why? Here's the deal: our form is bound to a Genus object:

210 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\OneToMany(
* targetEntity="GenusScientist",
* mappedBy="genus",
* fetch="EXTRA_LAZY",
* orphanRemoval=true,
* cascade={"persist"}
* )
*/
private $genusScientists;
... lines 82 - 208
}

That's the top-level object that we're modifying. And by default, Symfony reads all of the validation annotations from the top-level class... only. When it sees an embedded object, or an array of embedded objects, like the genusScientists property, it does not go deeper and read the annotations from the GenusScientist class. In other words, Symfony only validates the top-level object.

Double-lame! What the heck Symfony?

No no, it's cool, it's on purpose. You can easily activate embedded validation by adding a unique annotation above that property: @Assert\Valid:

211 lines src/AppBundle/Entity/Genus.php
... lines 1 - 6
use Symfony\Component\Validator\Constraints as Assert;
... lines 8 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\OneToMany(
* targetEntity="GenusScientist",
* mappedBy="genus",
* fetch="EXTRA_LAZY",
* orphanRemoval=true,
* cascade={"persist"}
* )
* @Assert\Valid()
*/
private $genusScientists;
... lines 83 - 209
}

That's it! Now refresh. Validation achieved!

Preventing Duplicate GenusScientist

But there's one other problem. I know, I always have bad news. Set one of the users to aquanaut3. Well, that's actually a duplicate of this one... and it doesn't really make sense to have the same user listed as two different scientists. Whatever! Save right now: it's all good: aquanaut3 and aquanaut3. I want validation to prevent this!

No problem! In GenusScientist add a new annotation above the class: yep, a rare constraint that goes above the class instead of a property: @UniqueEntity. Make sure to auto-complete that to get a special use statement for this:

78 lines src/AppBundle/Entity/GenusScientist.php
... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
... lines 13 - 14
* )
*/
class GenusScientist
{
... lines 19 - 77
}

This takes a few options, like fields={"genus", "user"}:

78 lines src/AppBundle/Entity/GenusScientist.php
... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
... line 14
* )
*/
class GenusScientist
{
... lines 19 - 77
}

This says:

Don't allow there to be two records in the database that have the same genus and user.

Add a nice message, like:

This user is already studying this genus.

78 lines src/AppBundle/Entity/GenusScientist.php
... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
* message="This user is already studying this genus"
* )
*/
class GenusScientist
{
... lines 19 - 77
}

Great!

Ok, try this bad boy! We already have duplicates, so just hit save. Validation error achieved! But... huh... there are two errors and they're listed at the top of the form, instead of next to the offending fields.

First, ignore the two messages - that's simply because we allowed our app to get into an invalid state and then added validation. That confused Symfony. Sorry! You'll normally only see one message.

But, having the error message way up on top... that sucks! The reason why this happens is honestly a little bit complex: it has to do with the CollectionType and something called error_bubbling. The more important thing is the fix: after the message option, add another called errorPath set to user:

79 lines src/AppBundle/Entity/GenusScientist.php
... lines 1 - 5
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
... lines 7 - 8
/**
... lines 10 - 11
* @UniqueEntity(
* fields={"genus", "user"},
* message="This user is already studying this genus",
* errorPath="user"
* )
*/
class GenusScientist
{
... lines 20 - 78
}

In a non embedded form, the validation error message from UniqueEntity normally shows at the top of the form... which makes a lot of sense in that situation. But when you add this option, it says:

Yo! When this error occurs, I want you to attach it to the user field.

So refresh! Error is in place! And actually, let me get us out of the invalid state: I want to reset my database to not have any duplicates to start. Now if we change one back to a duplicate, it looks great... and we don't have two errors anymore.

Fixing CollectionType Validation Bug

There is one small bug left with our validation! And it's tricky! To see it: add 2 new scientists, immediately remove the first, leave the yearsStudied field blank, and then submit. We should see a validation error appearing below the yearsStudied field. Instead, it appears no the top of the form! This is actually caused by a bug in Symfony, but we can fix it easily! The following code block shows the fix and has more details:

108 lines src/AppBundle/Form/GenusFormType.php
... lines 1 - 14
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
... lines 17 - 20
class GenusFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 25 - 56
$builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, 'onPreSubmit'));
}
... lines 59 - 66
/**
* This fixes a validation issue with the Collection. Suppose
* the following situation:
*
* A) Edit a Genus
* B) Add 2 new scientists - don't submit & leave all fields blank
* C) Delete the FIRST scientist
* D) Submit the form
*
* The one new scientist has a validation error, because
* the yearsStudied field was left blank. But, this error
* shows at the *top* of the form, not attached to the form.
* The reason is that, on submit, addGenusScientist() is
* called, and the new scientist is added to the next available
* index (so, if the Genus previously had 2 scientists, the
* new GenusScientist is added to the "2" index). However,
* in the HTML before the form was submitted, the index used
* in the name attribute of the fields for the new scientist
* was *3*: 0 & 1 were used for the existing scientists and 2 was
* used for the first genus scientist form that you added
* (and then later deleted). This mis-match confuses the validator,
* which thinks there is an error on genusScientists[2].yearsStudied,
* and fails to map that to the genusScientists[3].yearsStudied
* field.
*
* Phew! It's a big pain :). Below, we fix it! On submit,
* we simply re-index the submitted data before it's bound
* to the form. The submitted genusScientists data, which
* previously had index 0, 1 and 3, will now have indexes
* 0, 1 and 2. And these indexes will match the indexes
* that they have on the Genus.genusScientists property.
*
* @param FormEvent $event
*/
public function onPreSubmit(FormEvent $event)
{
$data = $event->getData();
$data['genusScientists'] = array_values($data['genusScientists']);
$event->setData($data);
}
}

Leave a comment!

  • 2017-04-12 Victor Bocharsky

    Hey Louis,

    You have a good tip in this error message: you just have to use the "Symfony\Component\Form\FormEvent" namespace in the GenusFormType class. ;)

    Cheers!

  • 2017-04-12 Osshe Louis

    After adding the Bug fix in my code, it shows this error instead:

    "Catchable Fatal Error: Argument 1 passed to AppBundle\Form\GenusFormType::onPreSubmit() must be an instance of AppBundle\Form\FormEvent, instance of Symfony\Component\Form\FormEvent given"

    any similar encounter?

  • 2017-04-10 Diego Aguiar

    Hey TamNC!

    Are you talking about the UniqueEntity error ? if that the case, remember to set up the "errorPath" option to the user field, so it will appear inside the user input field.

    Have a nice day!

  • 2017-04-09 TamNC

    Hi,

    I'm using Symfony 3.2.6. The problem of Collection Type is Error message on the top which may be fixed in this version. Is it right ?

  • 2016-12-16 weaverryan

    And actually, a good way to do that custom constraint might just be to do a Callback constraint on the Genus entity - you could easily loop through genusScientists to see if there are any duplicates. If that works (seems like it should), that would be a super simple solution!

  • 2016-12-16 weaverryan

    Hey Justas!

    Ah, I see! And I can repeat the issue. Here's the problem:

    Suppose you start with 2 scientists: scientist1 (e.g. GenusScientist id 5) and scientist2 (GenusScientist id 6). But, you delete scientist1 from the form and re-add him (just like you described). When you submit:

    1) the UniqueEntity constraint queries the database *two* times: once to see if there is already a GenusScientist with the same user+genus as scientist1 and once to see if there is already a GenusScientist with the same user+genus as scientist2. In both cases, a result is returned: GenusScientist id 5 for "scientist1" and GenusScientist id 6 for "scientist 2"

    2) the UniqueEntity constraint then checks to see if the queried GenusScientist matches exactly (===) the object that it was checking again... because if there was only 1 match found... it might be *this* GenusScientist, so it shouldn't be a duplicate. For "scientist 2", it compares GenusScientist id 6 with the one from the query, GenusScientist id 6. These are ===, so it does not create a validation constraint. BUT, for "scientist 1", it compares two GenusScientist objects that are *almost* (but not quite) identical. They have all the same fields... except for "id": the one from the database of course has id 6, but the one that was just submitted has a *null* id - because this was just created by our form system. Hence, a validation error is added.

    So, that's the problem :/. This issue might be related: https://github.com/symfony/.... How can we fix it? Ideally, we'd like our form to NOT create a *new* GenusScientist when one is simply removed and then re-added. The only way I can think to do this, however, would be to add some extra JS code that when the user creates a *new* scientist and assigns a user that was previously in the form, you actually dynamically update its "name" index to match the original one. In the above example, "scientist1" would originally have index 0. When you re-add it, it would have index 2. You would need to detect when the user drop-down is changed to "scientist1" and dynamically reset the index to 0. Then, when you submit, it will not look like a *new* entity anymore. Of course, that's a pretty ugly solution :). The only other alternative I can think of is to not use the UniqueEntity validator and write your own - something that looked at the "genusScientists" property directly, and checked the entire array to see if there were duplicates, before saving.

    I'm not sure there's a bug in the UniqueEntity validator... but certainly because of the way it's all setup, it just doesn't have enough knowledge to get things "perfect" for our situation.

    Let me know if that helps! Cheers!

  • 2016-12-16 Justas

    Hi Ryan,
    The problem is not caused by invalid state. You can remove all the scientists, save. Then add scientist1 and scientist2, save. Then when you edit the form again (you will see scientist1 and scientist2), remove the first scientist, without saving add a new one and select scientist1 again. In the form you will see scientist2 and scientist1, then try to save. It will not let you save the form saying that scientist1 is already working on the genus. Basically if you switch the order of the scientist in the collection the unique error appears.

  • 2016-12-16 weaverryan

    Hey Justas!

    If I understand correctly, you added 2 scientists to the Genus *before* configuring the UniqueEntity validation. And then, after adding UniqueEntity, you get this weird behavior. Is that correct? If so, I've seen this as well - I think because you're *starting* with your system in an invalid state (a state that would never be allowed if UniqueEntity were used), UniqueEntity gets confused. In the real world, you should of course always have UniqueEntity from the beginning, so you won't fall into this situation. But yea, I think there *is* some weird behavior if your system starts in this invalid state. How to fix it? Ideally, you don't need to :)... because you will use UniqueEntity to never allow this. But if you have an existing database with duplications and are now adding UniqueEntity, then you can either (A) run some sort of database migration to remove the duplicates or (B) create your own custom constraint instead of using UniqueEntity - I think UniqueEntity will always act in this way.

    Cheers!

  • 2016-12-16 Justas Kupliauskas

    Hi,
    I am trying to apply same structure to the similar project. However I came to an issue using @UniqueEntity. The issue arises when I have more than 1 scientist working on genus, then remove the first scientist (lets say aquanaut1@example.org), and then add it again as the last one. I can see that removeGenusScientist function is called and later addGenusScientist function is called, but I still get an "This user is already studying this genus" error under aquanaut1@example.org. How to fix this bug?

    Thank you in advance!