Un CRUD simple, personnalisable et puissant avec Symfony

Symfony a beau avoir de chouettes scripts pour générer automatiquement des CRUDs, je n’utilise jamais cette fonctionnalité pour tout un tas de raisons. D’une part, quand il s’agit d’insérer certaines actions, par exemple envoyer un mail à l’ajout d’un élément, il est plus efficace d’aller ajouter la ligne qui va bien dans son code quand on a une totale maîtrise de celui-ci. D’autre part, les CRUDs générés ne vont pas toujours coller à la charte graphique voulue. Et quand on nous fournit une charte complète à intégrer, il vaut mieux la découper pour l’intégrer directement sur une base vierge, plutôt que de retravailler ce qui a été généré.

MER IL ET FOU ! Il va tout faire à la main ! Que neni que neni, Symfony permet de faire des choses bien plus intéressantes que d’user plus que nécessaire les touches Ctrl, C et V de son clavier. Et comme on est malins mais surtout fainéants, on va faire en sorte d’écrire un code qui sera commun à tous nos modules, de façon à ce que l’ajout d’une nouvelle table sur le CRUD se fasse en deux lignes. Et une modification sur son fonctionnement sera faite à un seul endroit et répercutée sur tous nos modules.

Notre projet va être extrêmement simple : on a trois tables, référençant des utilisateurs, des groupes et des machines. Un utilisateur peut être présent sur plusieurs machines, appartenir à plusieurs groupes, bref, on a des relations en n-n entre toutes nos tables. On va donc avoir un modèle de données qui ressemble à ça :

Pourquoi avoir appelé les tables unix_user et unix_group et pas juste user et group ? Parce que faire un INSERT INTO group sous MySQL passe plutôt mal, « group » étant un mot clé réservé. On pourrait s’en sortir en échappant le nom de la table avec des quotes, malheureusement, Propel ne le fait pas. On doit donc se passer de la table group pour tout projet Symfony/Propel, ainsi que d’autres mots clés qu’il aurait pu être chouette d’utiliser. Attention aussi à éviter tout ce qui sera utilisé comme classe par le framework (Criteria, des Base*, etc…). Et pourquoi unix_user et pas user, alors que ça aurait quand même pu marcher ? Parce que tant qu’à faire, autant uniformiser un peu.

On cherche à faire un CRUD tout simple avec trois modules, un pour chaque table à gérer (sauf les tables de relation). A l’édition de chaque élément, des onglets nous permettront de gérer les relations avec les autres tables. Et parce qu’on est aware, on utilisera des onglets en Ajax pour ça. It’s sooooooooo web 2.0.

Comment faire notre base commune à tous les modules ? Vous savez sans doute que chaque classe *Actions hérite de sfActions. Mais avez vous déjà pensé à ajouter une classe intermédiaire ? Oui ? Et bien c’est exactement ce qu’on va faire ici : on crée une classe myActions (à placer dans apps/frontend/lib/) qui héritera de sfActions, et on modifie chaque module pour qu’il hérite de myActions plutôt que sfActions. Il est tout à fait possible de définir nos méthodes execute* dans cette classe commune, ce qui fait qu’aucun de nos modules n’aura besoin de définir d’actions (du moins pas pour l’instant). Chaque module référençant une table différente, il faudra juste préciser la table à utiliser. On fera cela dans la méthode execute, qui est appelée systématiquement à l’exécution d’un module, en créant une méthode initCrud qui prendra en paramètre le nom de la table :

 class myActions extends sfActions
 {
   private $className = null;

   // Initialisation du CRUD avec le nom de la classe du modèle à utiliser
   public function initCrud($className)
   {
     $this->className = $className;
   }

 ...

Avec cette seule information, notre classe myActions a tout ce dont elle a besoin pour fonctionner, et les fichiers actions.class.php ressembleront juste à ça :

 class groupsActions extends myActions
 {
   public function execute($request)
   {
     $this->initCrud('UnixGroup');
     // Toujours appeler la méthode du parent à la fin d'execute
     parent::execute($request);
   }
 }

Notez qu’il faut toujours appeler la méthode execute du parent à la fin de la méthode pour que le tout fonctionne correctement.

Il reste maintenant à écrire les méthodes de notre CRUD dans myActions :

 // Fonction d'index pour lister les éléments
 public function executeIndex(sfWebRequest $request)
 {
   $peerClassName = $this->className . 'Peer';
   // On personnalise le nom de variable contenant la liste des éléments
   $list_var = strtolower($this->className) . '_list';
   // On récupère la liste des éléments
   $this->$list_var = $peerClassName::doSelect(new Criteria());
 }

Pour l’index, on a juste besoin de faire un doSelect. Notre variable className initialisée plus tôt va nous servir à retrouver la bonne classe Peer à utiliser. On profite également de la capacité de PHP à pouvoir utiliser des noms de variables dynamiques pour que celle qui contiendra notre liste d’éléments ait un nom qui sera fonction du type de contenu : la variable sera $unixuser_list, $unixgroup_list ou $machine_list selon le module. On pourrait aussi garder un nom générique pour tous, comme $item_list, ce qui nous économiserait même quelques modifications sur les templates, mais on est des winners et on utilise à fond les possibilités de PHP. On pourra plus tard ajouter des options pour le tri, le filtrage, la pagination… mais on va rester simple pour le moment.

 // Fonction identique pour les méthodes d'ajout/édition
 public function executeEdit(sfWebRequest $request)
 {
   $peerClassName = $this->className . 'Peer';
   $formClassName = $this->className . 'Form';

   // Si on a un id défini, on édite un objet existant, il faut donc le récupérer
   $object = false;
   if ($request->getParameter('id'))
     $object = call_user_func(array($peerClassName, 'retrieveByPk'), $request->getParameter('id'));

   if ($object)
     $this->form = new $formClassName($object);  // Cas d'un objet existant
   else
     $this->form = new $formClassName();         // Cas d'un nouvel objet
 }

L’action edit sera la même que l’on crèe un nouvel objet ou qu’on en édite un ancien, selon qu’on a défini le paramètre id ou pas. On récupère donc le formulaire correspondant à la classe qu’on manipule. Je ne m’étendrais pas sur l’utilisation du framework de formulaires de Symfony, car celui-ci est plutôt bien documenté et parce qu’on n’en fait pas grand chose de particulier. J’y ai juste ajouté quelques validateurs pour traiter les erreurs, que vous pourrez voir dans les sources du projet.

 // Fonction pour la création/modification d'un objet
 public function executeUpdate(sfWebRequest $request)
 {
   $peerClassName = $this->className . 'Peer';
   $formClassName = $this->className . 'Form';

   $this->form = new $formClassName();
   $params = $request->getParameter($this->form->getName());
   // Si on a un id défini, on charge l'objet correspondant
   if (isset($params['id']) && $params['id'])
     $object = call_user_func(array($peerClassName, 'retrieveByPk'), $params['id']);

   // On recrèe le formulaire en chargeant l'objet
   if (isset($object) && !is_null($object))
     $this->form = new $formClassName($object);

   $this->processForm($request, $this->form);
   $this->setTemplate('edit');
 }

 // Validation du formulaire pour les ajouts/éditions
 protected function processForm(sfWebRequest $request, sfForm $form)
 {
   $form->bind($request->getParameter($form->getName()), $request->getFiles($form->getName()));
   if ($form->isValid())
   {
     // En cas de succès, on redirige vers la page souhaitée
     $object = $form->save();
     $this->redirect($this->getModuleName() . '/edit?id='.$object->getId());
   }
 }

Pour l’action effectuant les ajouts et modifications en base, même principe, tout est regroupé dans une même méthode. C’est un peu plus lourd ici puisque l’id étant intégré au tableau destiné au formulaire, il ne peut pas être récupéré directement et on doit d’abord instancier un premier formulaire vide. Si on a chargé un objet, on recrée le formulaire pour le prendre en compte. Les habitués de la génération de CRUD Symfony reconnaîtront la méthode processForm, qui effectue le traitement du formulaire, ici quelque peu modifiée pour être adaptée à n’importe quel module. Étant parti sur la base d’un module de CRUD auto généré, j’en ai conservé certaines parties. J’ai tout de même supprimé la distinction entre les méthodes create et update, afin d’alléger le code.

 // Fonction pour la suppression d'un ou plusieurs objets
 public function executeDelete(sfWebRequest $request)
 {
   $peerClassName = $this->className . 'Peer';

   foreach ($request->getParameter('delete') as $id)
   {
     if ($object = call_user_func(array($peerClassName, 'retrieveByPk'), $id))
     {
       try {
         $object->delete();
       } catch (PropelException $e) {

       }
     }
   }

   $this->redirect($this->getModuleName() . '/index');
 }

La suppression doit pouvoir être faite sur plusieurs objets à la fois, via des cases à cocher dans les différentes listes. Rien de bien compliqué ici : on récupère les objets un à un et on en fait la suppression. On pourrait améliorer les choses en passant par un doDelete contenant un Criteria avec la liste des ids à supprimer, mais la flemme. J’ai également laissé de côté la gestion des erreurs, dont il sera question plus tard.

Voilà, en quelques lignes, une base de CRUD fonctionnelle et qui permet d’ajouter facilement de nouvelles tables en une poignée de minutes. Je ne détaille pas la création des templates associés, pour lesquels seuls deux sont nécessaires par module (index et edit), l’idée étant que chacun puisse les adapter à sa propre charte grapĥique. Ils sont toutefois présents dans l’archive jointe, sinon, ça ne marcherait pas (forcément). Si pour l’instant il s’agit beaucoup de copier/coller, on verra plus tard comment simplifier leur création en utilisant des helpers. Vous remarquerez aussi que je ne me suis pas foulé pour faire quelque chose de joli et de pratique, mais après tout, ce n’est pas le but ici.

Et là vous me dites : où sont nos beaux onglets Ajaxisés pour gérer les relations ? Parce que oui, pour l’instant nos tables supplémentaires ne servent strictement à rien. Et bien ceci, ainsi que plein d’autres bonnes choses, sont à venir dans la suite du tutoriel.

Le code source du tutoriel est ici

Les commentaires sont clôturés.