Nesting Doctrine’s relations in sfForm

I regularly need a form to edit more than one object at once. To explain this more clearly: imagine an Author - Book relation. Doctrine builds you standard forms for the Author and Book objects, but you can only use them separate. Why not combine the forms using code like in the following lines?

$form = new AuthorForm($author);
$form->embedRelation('Books');

Such code extends functionality of your forms with the knowledge already defined in the Doctrine-schema of your project. It will reuse your already available forms into a really smart editor. Very DRY to my opinion…

summing-forms

This article explains how to extend the sfDoctrineForm and make the above code work.


Example schema and definition

Let’s take the typical Author-Book relation as an example. An author has written books, each book has an author. This gives the following schema & fixtures: (config/doctrine/schema.yml, data/fixture/data.yml)

---
Author:
  columns:
    name: string(255)
    description: string
 
Book:
  columns:
    author_id: integer(20)
    title: string(255)
    released: integer(20)
  relations:
    Author:
      foreignAlias: Author
data/fixtures/data.yml:
Author:
  kluun:
    name: Kluun
    description: Author living in Amsterdam
 
Book:
  komt:
    title: Komt een vrouw bij de dokter
    released: 2003
    Author: kluun
  weduw:
    title: De weduwnaar
    released: 2006
    Author: kluun

After doing doctrine:build-all-load for setting up the model&forms we can create a CRUD by executing something like doctrine:generate-module frontend author Author. This gives a familiar CRUD interface:

basic crud list

Steps

So far so good. The steps we need to do to get the right result are:

  1. add a BookForm form for each $author['Books'] object

  2. when data comes back, save the data, also for the nested objects.

Array structure

We have to start with the end in mind. After submitting and validating a form, the returned data should match the form Doctrine uses. So we start with step #2. Each Doctrine record object has a method ->fromArray(boolean $deep), which populates the object. When $deep is true it also populates nested records, this is the functionality we need! If we can create a sfForm which returns data in the exact array structure needed by fromArray, then we can directly populate the record object with the $form->getValues(). (Actually this is the way sfDoctrineForm populates records, but $deep is set to false so it only populates the current object)

Now we need to know which structure this array has. Luckily Doctrine supplies the ‘inverse’ method of fromArray, named ->toArray(boolean $deep). This method gives us the array needed to populate the current object. A sample output of $author->toArray() is below:

Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [Books] => Array
        (
            [0] => Array
                (
                    [id] => 5
                    [author_id] => 3
                    [title] => Komt een vrouw bij de dokter
                    [released] => 2003
                )
            [1] => Array
                (
                    [id] => 6
                    [author_id] => 3
                    [title] => De weduwnaar
                    [released] => 2006
                )
        )
)

Let’s give it a first try:

$author = Doctrine::getTable('Author')->find('3');
$book1 = Doctrine::getTable('Book')->find('5');
$book2 = Doctrine::getTable('Book')->find('6');
 
$form = new AuthorForm($author);
$form->embedForm('book1', new BookForm($book1));
$form->embedForm('book2', new BookForm($book2));
 
echo "<table>".$form."</table>";

After doing a $form->bind($request->getParameter('author')); the form returns values: (`$form->getValues()’)

Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [book1] => Array
        (
            [id] => 5
            [author_id] => 3
            [title] => Komt een vrouw bij de dokter
            [released] => 2003
        )
    [book2] => Array
        (
            [id] => 6
            [author_id] => 3
            [title] => De weduwnaar
            [released] => 2006
        )
)

Getting close, but actually we need a temporary form for embedding all book-objects first. When all forms are added we can embed this temporaryform with the name of the relation into the form.

The following code gives the right $form->getValues() array structure:

$form = new AuthorForm($author);
 
$tempform = new sfForm();
$tempform->embedForm('0', new BookForm($book1));
$tempform->embedForm('1', new BookForm($book2));
 
$form->embedForm('Books',$tempform);
Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [Books] => Array
        (
            [0] => Array
                (
                    [id] => 5
                    [author_id] => 3
                    [title] => Komt een vrouw bij de dokter
                    [released] => 2003
                )
            [1] => Array
                (
                    [id] => 6
                    [author_id] => 3
                    [title] => De weduwnaar
                    [released] => 2006
                )
        )
)

Bingo, this response exactly matches the array we got from the $author->toArray(true) method!

Hooking things up

Now we have to implement this in a new function. The doctrine:build-forms command also creates a BaseFormDoctrine.class.php where we can put our code. It inherits from sfDoctrineForm and is extended by all generated forms. BaseFormDoctrine.class.php is located in the lib/form/doctrine/base directory.

The following function will do the trick:

  public function embedRelation($relationName)
  {    
    $formClass = $this->object->getTable()->getRelation($relationName)->getClass()."Form";
 
    $subForm = new sfForm();
    foreach ($this->object[$relationName] as $index => $childObject)
    {
      $form = new $formClass($childObject);
    }      
    $this->embedForm($relationName, $subForm);
  }

Saving the values

Using the above code, the form will display right, but only the values of the Author are stored. Now we need to override some default behaviour. In sfDoctrineForm the method updateDefaultsFromObject merges arrays only one level deep; all array_merge calls should be changed into sfForm::deepArrayUnion calls. The function updateObject should also be changed to enable ‘deep’ setting of values, change the last line into $this->object->fromArray($values, true);. See the attached code for my full version of BaseFormDoctrine.class.php.

Reflection

Using relations to extend your forms is a powerful feature. Real-life usage demands a bit more than written in this article:

  1. There’s a danger of recursion, so the system has to detect this.

  2. The article only spoke about one-to-many relations, but one-to-one should also be handled.

  3. One-to-many lists (in our example the dropdown in a Book form for selecting the Author) should be removed from the form, as it creates an unintuitive interface.

  4. The developer should be able to override the classname of the embedded form and should also be able to change the options of the embedded forms

All these features are implemented in the attached code. Have a look and have fun! Thanks for reading

Last note: Today the sfForm was updated and now has a a new method ->getEmbeddedForms(). This enables other approaches to the problem, I’ll update when the sfDoctrinePlugin is up to date with this new feature.

baseformdoctrineclass

Tags: , ,

14 Responses to “Nesting Doctrine’s relations in sfForm”

  1. RonaldLI says:

    Спасибо за текст! Очень понравилось

  2. wheniaenriche says:

    Спасибо за пост! Добавил блог в RSS-ридер, теперь читать буду регулярно..

  3. First of all congratulation for such a great site. I learned a lot reading article here today. I will make sure i visit this site once a day so i can learn more.

  4. vukadinov says:

    Thank you for sharing. Your article is very helpful!

    There is one mistake in article (the source file is o.k.):

    $subForm = new sfForm(); foreach ($this->object[$relationName] as $index => $childObject) { $form = new $formClass($childObject); } $this->embedForm($relationName, $subForm);

    childObject forms are not embedded. You just create a new form, which is overridden in next iteration, but is never used.

  5. Alex Stoneham says:

    Great article, except you don’t want to edit the generated base form class, just the generated form class, the base form class will get overwritten [this is regards to the section [Hooking things up]

  6. Alex Stoneham says:

    Whoops my mistake, it is BaseFormDoctrine.class.php

  7. shahin zabeti says:

    i am very proud to visit your blog. very useful

  8. Patrick says:

    Has this method changed at all when using the symfony 1.2 branch (with the latest doctrine 1.0 plugin)?

    I’m using a very simple 1:m relationship and the M object isn’t saving.

  9. Dennis Gearon says:

    Why do you put it into the base class? Won’t it be writtn over by any schema changes/model rebuilding?

  10. Dennis Gearon says:

    Answered my own question, eventually, LOL! You put it into the ‘intermediary’ base class between the real Doctrine base class and the Base classes of all the models. Just exacly what that class is for. Nice thinking :-)

  11. Dennis Gearon says:

    OK, done a LOT more research and attended a symfony workshop in English @ SolutionSet in San Francisco, CA, USA.

    Coupl of Questions, I hope that the author is still watching the blog - he inspired some great changes in the new 1.3/2.0 Symfony branches.

    (1) The relations in the Schema for the –>Book<– Object/Table are SINGULAR, i.e. ‘Author’. But the collection/relation is embedded from the Author side as PLURAL, i.e. ‘Books’. Wierd, understandable. The 1.3 branch of Symfony and your code seem to require a PLURAL named array/collection.

    Am I right, this what the ‘create deep with array’ is expecting, a plural named, ‘reverse relationship referenced’ array?

    (2) The tables named in other languages in a UTF8/I18n environment, for instance, Asian charactered languages, are the collection names the parent going to end in an ’s’ character? Will PHP handle this?

  12. Dennis Gearon says:

    I think that you meant the ‘foreignAlias’ under Book to be ‘Books’, not ‘Author’, right?

  13. Dennis Gearon says:

    I can never get this to work, using the on screen code. Nor can I ever get toArray(true) to work and give me deep arrays. Somehow I think there some disconnect betwen schemas and class behavior in the new doctrine stuff (1.3beta)

  14. hi, thanks for the useful bingo info mate, there’s so many options these days it’s hard to know where to look for relevant info, thank you.

Leave a Reply