Buy

Adding to a Collection: Cascade Persist

After adding a new GenusScientist sub-form and submitting, we're greeted with this wonderful error!

Expected argument of type User, GenusScientist given

Updating the Adder Method

But, like always, look closely. Because if you scroll down a little, you can see that the form is calling the addGenusScientist() method on our Genus object:

209 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 178
public function addGenusScientist(User $user)
{
... lines 181 - 187
}
... lines 189 - 207
}

Oh yea, we expected that! But, the code in this method is still outdated.

Change the argument to accept a GenusScientist object. Then, I'll refactor the variable name to $genusScientist:

208 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 179
public function addGenusScientist(GenusScientist $genusScientist)
{
if ($this->genusScientists->contains($genusScientist)) {
return;
}
$this->genusScientists[] = $genusScientist;
}
... lines 188 - 206
}

As you guys know, we always need to set the owning side of the relationship in these methods. But, don't do that... yet. For now, only make sure that the new GenusScientist object is added to our array.

With that fixed, go back, and refresh to resubmit the form. Yay! New error! Ooh, this is an interesting one:

A new entity was found through the relationship Genus.genusScientists that was not configured to cascade persist operations for GenusScientist.

Umm, what? Here's what's going on: when we persist the Genus, Doctrine sees the new GenusScientist on the genusScientists array... and notices that we have not called persist on it. This error basically says:

Yo! You told me that you want to save this Genus, but it's related to a GenusScientist that you have not told me to save. You never called persist() on this GenusScientist! This doesn't make any sense!

Cascade Persist

So what's the fix? It's simple! We just need to call persist() on any new GenusScientist objects. We could add some code to our controller to do that after the form is submitted:

88 lines src/AppBundle/Controller/Admin/GenusAdminController.php
... lines 1 - 15
class GenusAdminController extends Controller
{
... lines 18 - 34
public function newAction(Request $request)
{
$form = $this->createForm(GenusFormType::class);
// only handles data on POST
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
... lines 42 - 43
$em = $this->getDoctrine()->getManager();
$em->persist($genus);
$em->flush();
... lines 47 - 53
}
... lines 55 - 58
}
... lines 60 - 87
}

Or... we could do something fancier. In Genus, add a new option to the OneToMany: cascade={"persist"}:

208 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 - 206
}

This says:

When we persist a Genus, automatically call persist on each of the GenusScientist objects in this array. In other words, cascade the persist onto these children.

Alright, refresh now. This is the last error, I promise! And this makes perfect sense: it is trying to insert into genus_scientist - yay! But with genus_id set to null.

The GenusScientistEmbeddedForm creates a new GenusScientist object and sets the user and yearsStudied fields:

37 lines src/AppBundle/Form/GenusScientistEmbeddedForm.php
... lines 1 - 12
class GenusScientistEmbeddedForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('user', EntityType::class, [
... lines 19 - 23
])
->add('yearsStudied')
;
}
... lines 28 - 36
}

But, nobody is ever setting the genus property on this GenusScientist.

This is because I forced you - against your will - to temporarily not set the owning side of the relationship in addGenusScientist. I'll copy the same comment from the remover, and then add $genusScientist->setGenus($this):

210 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 179
public function addGenusScientist(GenusScientist $genusScientist)
{
... lines 182 - 186
// needed to update the owning side of the relationship!
$genusScientist->setGenus($this);
}
... lines 190 - 208
}

Owning side handled!

Ok, refresh one last time. Boom! We now have four genuses: this new one was just inserted.

And yea, that's about as complicated as you can get with this stuff.

Don't Purposefully Make your Life Difficult

Oh, but before we move on, go back to /genus, click a genus, go to one of the user show pages, and then click the pencil icon. This form is still totally broken: it's still built as if we have a ManyToMany relationship to Genus. But with our new-found knowledge, we could easily fix this in the exact same way that we just rebuilt the GenusForm. But, since that's not too interesting, instead, open UserEditForm and remove the studiedGenuses field:

42 lines src/AppBundle/Form/UserEditForm.php
... lines 1 - 14
class UserEditForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
... lines 20 - 24
->add('studiedGenuses', EntityType::class, [
'class' => Genus::class,
'multiple' => true,
'expanded' => true,
'choice_label' => 'name',
'by_reference' => false,
])
;
}
... lines 34 - 40
}

Then, open the user/edit.html.twig template and kill the render:

25 lines app/Resources/views/user/edit.html.twig
... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
<div class="col-xs-8">
... lines 7 - 8
{{ form_start(userForm) }}
... lines 10 - 16
{{ form_row(userForm.studiedGenuses) }}
... lines 18 - 19
{{ form_end(userForm) }}
</div>
</div>
</div>
{% endblock %}

Finally, find the User class and scroll down to the adder and remover methods. Get these outta here:

243 lines src/AppBundle/Entity/User.php
... lines 1 - 16
class User implements UserInterface
{
... lines 19 - 222
public function addStudiedGenus(Genus $genus)
{
if ($this->studiedGenuses->contains($genus)) {
return;
}
$this->studiedGenuses[] = $genus;
$genus->addGenusScientist($this);
}
public function removeStudiedGenus(Genus $genus)
{
if (!$this->studiedGenuses->contains($genus)) {
return;
}
$this->studiedGenuses->removeElement($genus);
$genus->removeGenusScientist($this);
}
}

Go back to refresh the form. Ok, better! This last task was more than just some cleanup: it illustrates an important point. If you don't need to edit the genusesStudied from this form, then you don't need all the extra code, especially the adder and remover methods. Don't make yourself do extra work. At first, whenever I map the inverse side of a relationship, I only add a "getter" method. It's only later, if I need to update things from this side, that I get fancy.

Oh, and also, remember that this entire side of the relationship is optional. The owning side of the relationship is in GenusScientist. So unless you need to be able to easily fetch the GenusScientist instances for a User - in other words, $user->getStudiedGenuses() - don't even bother mapping this side. We are using that functionality on the user show page, so I'll leave it.

Leave a comment!

  • 2017-05-01 Victor Bocharsky

    Hey Luc,

    As for me, I agree with you, I think setGenus() method should be called first, before any if statement in addGenusScientist() / removeGenusScientist() and it makes perfect sense for me. This will fix some potential problems like this workflow:
    1. $this->addGenusScientists()->add($genusScientist); // somewhere in the code we add an object to the collection manually but don't set owning side! And then further...
    2. ->addGenusScientist($genusScientist); // So the object is already in the collection, BUT if we call this method - we'll ignore setting the owning side because contains() return true now.
    Yes, it looks like a developer mistake, but we can cover it with calling setGenus() in the first place in both addGenusScientist() / removeGenusScientist().

    What about contains() - we're operating objects, and since this method uses === in http://www.doctrine-project... - we don't have extra checks to validate that each field is matched. If we have another object - this check will fail, if we have the same object - no matter whether its fields are different or no, because we have exactly the same object, and if some fields were changed, Doctrine will do everything to store those fields in the DB on the next flush().

    Cheers!

  • 2017-04-23 Luc Hamers

    Hello,

    I have a problem with the addGenusScientist and removeGenusScientist methods. In my application, I use checkboxes to set or remove entries to or from the mapping table (I call it genusScientist table here for better understanding). When adding, the idea is, that I call addGenusScientist in genus, pass it a genusScientist object where only the user is set. In my opinion, this doesn't work correctly with the current implementation:


    public function addGenusScientist(GenusScientist $genusScientist)
    {
    if ($this->genusScientists->contains($genusScientist)) {
    return;
    }

    $this->genusScientists[] = $genusScientist;
    // needed to update the owning side of the relationship!
    $genusScientist->setGenus($this);
    }

    public function removeGenusScientist(GenusScientist $genusScientist)
    {
    if (!$this->genusScientists->contains($genusScientist)) {
    return;
    }

    $this->genusScientists->removeElement($genusScientist);
    // needed to update the owning side of the relationship!
    $genusScientist->setGenus(null);
    }

    I have 3 problems with that:

    1. the setGenus line should be the first action in the method, otherwise contains will never return true, even if the combination of genus and user already exists.

    2. contains apparently only then finds the entry in the collection, when ALL fields in $genusScientist are set exactly set as in the matching object in $this->genusScientists, i.e. not only user and genus have to be set, but also id and yearsStudies. If not, contains will not find them and I will have duplicate entries in my genusScientist table.

    3. the same problem with contains happens in the removeGenusScientist method. Unless I fetched the genusScientist object from the database (i.e. all fields are set) before passing to the removeGenusScientist, contains will never find it. Even worse, the removeElement will then not work.

    In my code, I implemented my own contains method, which only searches for the keys (genus and user) in this "mapping table with additional fields". But this feels strange, since the way it is implemented here can also be found on other examples on the internet.

    Did I misunderstand the goal of the if-statements in add-/removeGenusScientists or is there really a bug in this code?

  • 2017-01-19 Vince Liem

    Hey, I fixed it by placing the form_start at the beginning and form_end at the end of the file

  • 2017-01-19 Victor Bocharsky

    Hey Vince,

    Hm, if you don't see added elements in POST request on the server - check the name attribute of added HTML elements. Is it exactly the same as manually added one? The difference should be only with the numbers, i.e. [0], [1], etc. You probably just make a misprint in the field name.

    Also I suppose you use collection type for it, so please, double check that you have an "allow_add" => true, option.

    And the last but not least, I think you use some JS code to add more pictures in the form. Do you use jQuery or other library / JS framework for it? Are you sure that your code writes directly to the DOM (not virtual DOM)?

    Cheers!

  • 2017-01-19 Vince Liem

    Hello super person,

    So in my project one product can have many pictures with titles and descriptions. And I'm able to get the remover method to work.

    BUT it seems that I can't get the adder to work. if I dump $form->getData(); it seems that everything I add gets ignored. While the multiple pictures I manually added in the database doesn't get ignored, and I can edit them in the form. In "inspect element" in the HTML form. I can see that embedded forms gets the necessary [0], [1] etc in the name, but it isn't visible when I post.

    Any ideas what I accidentally missed?