Symfony1 et les formulaires imbriqués avec Doctrine

De e-glop
Révision datée du 5 juin 2013 à 14:45 par BeTa (discussion | contributions) (Des cas particuliers)
(diff) ← Version précédente | Voir la version actuelle (diff) | Version suivante → (diff)

Symfony, quand il est adossé à Doctrine pour l'accès aux données, permet d'accéder à une fonctionnalité très pratique lorsque l'on veut avoir un seul et même formulaire pour un objet et une ou plusieurs dépendances :

sfFormDoctrine::embedRelation()

La difficulté est que la documentation n'est pas satisfaisante à mes yeux. Voici donc un complément d'information :

Posons deux contextes différents

Un seul objet lié, one-to-one

Nous avons alors un schéma de ce type :

Picture:
  columns:
    name:
      type: string
      notnull: true
      notblank: true
    type:
      type: string(255)
      notnull: true
      notblank: true
    content:
      type: blob
      notnull: true
    width: integer
    height: integer
Group:
  columns:
    name:
      type: string
      unique: true
      notblank: true
      notnull: true
    picture_id:
      type: integer
      unique: true
  relations
    Picture:
      foreignAlias: Groups
      onDelete: SET NULL
      onUpdate: CASCADE

Des groupes étant illustrés ou non par une image, issue d'un modèle extérieur au groupes de manière à pouvoir être réutilisé par ailleurs... pas très complexe en soit. La plus grosse particularité est que la méthode sfFormDoctrine::embedRelation() est prévue pour partir d'un objet Picture alors que là nous partirons d'un objet Group...

Une relation one-to-many, plusieurs objets liés

YOB:
  columns:
    year:
      type: integer
      notnull: true
    month: integer
    day: integer
    name: string(255)
    contact_id: integer
  relations:
    Contact:
      foreignAlias: YOBs
      onDelete: CASCADE

Nous ne précisons pas le modèle Contact car cela a peu d'intérêt.

Une relation many-to-many, relations multiples

À venir ...


Essayons et constatons les difficultés

one-to-one, avec des images et des groupes

La particularité est que nous avons un formulaire de téléchargement de fichier à partir duquel la majorité des champs seront précisés. Essayons pour voir :

// lib/form/doctrine/PictureForm.class.php
class PictureForm extends BasePictureForm
{
 // a hack for blob content which was erased on form initialization
 public function __construct(Picture $object = NULL)
 {
   if (!( $object instanceof Picture ))
     return parent::__construct();
   
   $buf = $object->content;
   $r = parent::__construct($object);
   if ( $object->content !== $buf )
     $object->content = $buf;
   
   return $r;
 }
 public function configure()
 {
   unset($this->widgetSchema['content'],$this->validatorSchema['content']);
   $this->widgetSchema   ['content_file'] = new sfWidgetFormInputFile();
   $this->validatorSchema['content_file'] = new sfValidatorFile(array(
     'mime_types' => array('image/gif', 'image/jpg', 'image/png', 'image/jpeg'),
   ));
   $this->validatorSchema['type']->setOption('required',false);
   $this->validatorSchema['name']->setOption('required',false);
 }
 
 public function doSave($con = NULL)
 {
   $this->translateValues();
   return parent::doSave($con);
 }
 
 // transforming the sfValidatedFile into Picture's properties
 public function translateValues()
 {
   $this->values['content']  = base64_encode(file_get_contents($this->values['content_file']->getTempName()));
   $this->values['name']     = $this->values['content_file']->getOriginalName();
   $this->values['type']     = $this->values['content_file']->getType();
   unset($this->values['content_file']);
 }
}

Nous testons ce formulaire dans un module ./symfony doctrine:generate-admin XXX Picture et tout fonctionne très bien. Passons maintenant à sa version "embarquée" dans le sfFormDoctrine du modèle Group :

// lib/forms/doctrine/GroupForm.class.php
class GroupForm extends BaseGroupForm
{
  // ...
  public function doSave($con = NULL)
  {
    $picform_name = 'Picture';
    $file = $this->values[$picform_name]['content_file'];
    unset($this->values[$picform_name]['content_file']);
    
    if (!( $file instanceof sfValidatedFile ))
      unset($this->embeddedForms[$picform_name]);
    else
    {
      // data translation
      $this->values[$picform_name]['content']  = base64_encode(file_get_contents($file->getTempName()));
      $this->values[$picform_name]['name']     = $file->getOriginalName();
      $this->values[$picform_name]['type']     = $file->getType();
      $this->values[$picform_name]['width']    = 24;
      $this->values[$picform_name]['height']   = 16;
    }
    
    return parent::doSave($con);
  }
  
  // ...
  
  public function configure()
  {
    // ...
    $this->embedRelation('Picture');
    foreach ( array('name', 'type', 'version', 'height', 'width',) as $fieldName )
      unset($this->widgetSchema['Picture'][$fieldName], $this->validatorSchema['Picture'][$fieldName]);
    $this->validatorSchema['Picture']['content_file']->setOption('required',false);
    // ...
  }
}

Puisque tous les champs sont définis par un fichier à télécharger, nous procédons à un unset des champs inutiles. Parce qu'un Group peut avoir ou non une Picture, on précise que le fichier téléchargé n'est pas requis. La fonction doSave() surchargée permet d'utiliser, comme dans le PictureForm le fichier uploadé comme point de départ pour les données à enregistrer en base de données.

Si on essaie le formulaire en l'état on s'aperçoit :

  • La Picture est bien créée
  • Le groupe est bien sauvegardé
  • Le lien entre les deux est perdu


one-to-many, entre des "YOB" membres de la famille d'un Contact

YOB signifie en fait ici Year-Of-Birth. C'est une table qui a évoluée de la définition d'années de naissance en une table définissant les membres d'une famille, avec leur date de naissance complète. Voilà l'explication quant au nom.

Nous allons, dans le formulaire correspondant au modèle Contact, embarquer les sous-objets YOB :

class ContactForm extends BaseContactForm
{
 // ...
 public function configure()
 {
   // ...
   $this->getObject()->YOBs[] = new YOB;
   $this->embedRelation('YOBs');
   
   // ...
   parent::configure();
 }
}

D'après nos essais, ça marche à une condition : toujours ajouter un YOB au moment de la mise à jour d'un Contact. De la même manière, impossible d'en supprimer un.

Solutionnons tout cela ...

one-to-many, avec des images et des groupes

Le problème est tout simple :

  • apparemment, la méthode sfFormDoctrine::embedRelation() considère les relations dans le sens PictureGroup et non l'inverse ; autrement dit de l'objet unique vers ses multiples objets liés, disposant donc de la clé étrangère référençant l'objet initial.
  • l'objet Group dont le formulaire courant est issu a une propriété picture_id qui vient écraser le lien qui est fait par le $this->embedRelation('Picture');
  • nous allons donc supprimer les widget et validator picture_id du formulaire, pour laisser seule la méthode sfFormDoctrine::embedRelation() gérer ce lien
// lib/forms/doctrine/GroupForm.class.php
class GroupForm extends BaseGroupForm
{
  // ...
  public function configure()
  {
    // ...
    $this->embedRelation('Picture');
    foreach ( array('name', 'type', 'version', 'height', 'width',) as $fieldName )
      unset($this->widgetSchema['Picture'][$fieldName], $this->validatorSchema['Picture'][$fieldName]);
    $this->validatorSchema['Picture']['content_file']->setOption('required',false);
    unset($this->widgetSchema['picture_id'], $this->validatorSchema['picture_id']); // RETRAIT DES WIDGET ET VALIDATOR
    // ...
  }
}

Et le tour est joué...


one-to-many, entre des "YOB" membres de la famille d'un Contact

Notre problème est donc de permettre la mise à jour d'un Contact, via son formulaire, sans être obligé d'ajouter un YOB à chaque fois... voire par la même occasion, de pouvoir en supprimer au besoin.

L'une des propriétés de la classe YOB est que sa propriété year est obligatoirement non NULL. Ainsi nous allons baser le fonctionnement du formulaire ContactForm incluant les relations YOB sur ce point pour savoir s'il faut ajouter ou ignorer/supprimer l'objet lié :

class ContactForm extends BaseContactForm
{
  // ...
  protected function doSave($con = NULL)
  {
   foreach ( $this->values['YOBs'] as $key => $values )
   if (! (isset($values['year']) && trim($values['year'])) )
     unset($this->object->YOBs[$key], $this->embeddedForms['YOBs']->embeddedForms[$key], $this->values['YOBs'][$key]);
   
   // ...
   return parent::doSave($con);
 }
}

Par précaution, nous retirons les YOBs ayant des propriétés year à NULL ou équivalent de tous les éléments pouvant avoir une incidence quelque par sur l'enregistrement du Contact lié :

  • de l'objet Contact lié au formulaire ContactForm
  • des formulaires embarqués dans le formulaire ContactForm
  • des valeurs du formulaire ContactForm

Des cas particuliers

Rien ne peut rouler sans cas particuliers...

Une exception Doctrine_Connection_Pgsql_Exception est levée pour "une valeur NULL viole la contrainte (...)"

Par exemple :

Doctrine_Connection_Pgsql_Exception
SQLSTATE[23502]: Not null violation: 7 ERREUR: une valeur NULL viole la contrainte NOT NULL de la colonne « name »

Nous avons alors contourné ce problème en complétant la méthode sfFormDoctrine::doSave() tel que suit :

class ContactForm extends BaseContactForm
{
  // ...
  protected function doSave($con = NULL)
  {
    // ...
    foreach ( $this->values['Relationships'] as $key => $values )
    if (!( isset($values['to_contact_id']) && $values['to_contact_id'] )
      ||!( isset($values['contact_relationship_type_id']) && $values['contact_relationship_type_id'] ))
    {
      unset(
        $this->object->Relationships[$key],
        $this->embeddedForms['Relationships']->embeddedForms[$key],
        $this->values['Relationships'][$key]
      );
    }
    else
      $this->object->Relationships[$key]->Contact = NULL; // here is the hack ...
    // ...
  }
  // ...
}

Le fait de forcer le $this->object->Relationships[$key]->Contact à NULL évite que le Doctrine_Record::save() n'aille chercher un objet n'existant pas, et donc essaie de l'insérer en base avec des valeurs NULL interdites.


Les sous-objets déjà présents disparaîssent, seuls les nouveaux sont correctement enregistrés

Dans le cas d'une table où la relation serait définie dans son schéma Doctrine, une méthode BaseXxxxxForm::saveYyyyyyList() a été ajoutée automatiquement, qui vient parasiter notre fonctionnement pourtant bien huilé. Autrement dit, voici un exemple :

Contact:
  columns:
    name: string(255)
  relations:
    Relations:
      refClass: Relationship
      foreignAlias: BackRelations
      local: from_contact_id
      foreign: to_contact_id
Relationship:
  columns:
    name: string(255)
    from_contact_id:
      type: integer
      notnull: true
    to_contact_id:
      type: integer
      notnull: true
  relations:
    ContactOrig:
      local: from_contact_id
      class: Contact
      foreignAlias: Relationships
      onDelete: CASCADE
      onUpdate: CASCADE
    Contact:
      local: to_contact_id
      class: Contact
      foreignAlias: ForeignRelationships
      onDelete: CASCADE
      onUpdate: CASCADE

Nous aurions pu éviter de définir la relation Many-to-Many car nous allons utiliser ::embedRelation('Relations'), mais nous en aurons besoin par ailleurs... L'"effet de bord" de cette relation Many-to-Many est la création dans le BaseContactForm d'une méthode BaseContactForm::saveRelationsList() qui vient perturber le fonctionnement de notre formulaire, puisque nous n'utiliserons pas ce widget mais un embedForm pour pouvoir spécifier le name de la Relation...

Bref, voici donc la méthode à utiliser :

// lib/forms/doctrine/ContactForm.class.php
class ContactForm extends BaseContactForm
{
  // ...
  public function configure()
  {
    // ...
    $this->embedRelation('Relations');
    // ...
    unset($this->widgetSchema['relations_id']);
    // ...
  }
}

Ainsi, cela neutralise la méthode BaseContactForm::saveRelationsList() qui s'arrête si le widget n'est pas défini. Nous avons donc notre solution... CQFD.