Utiliser le nested set (arbre par représentation intervallaire) avec doctrine et symfony

Je vais ici vous expliquer comment utiliser le nestedset pour créer un système de catégorie gérant les parents et enfants sur une infinité de niveaux.

La première étape est de comprendre le principe de gestion des arbres par représentation intervallaire.

Bon, vous avez pris le temps de lire l’article présenté ci-dessus on passe maintenant à la pratique avec symfony et doctrine.

L’idée ici est de créer un système de catégories et de sous catégories sans limitation de niveaux

Création du schéma de base de données


Category:
  actAs:
    NestedSet:
      hasManyRoots: true
      rootColumnName: root_id
  columns:
    name: string(255)

Voila avec ce code vous allez demander à doctrine de créer une table capable de gérer votre arbre.
hasManyRoots: true spécifie qu’il peut y avoir des parents sur plusieurs niveaux (c’est à dire qu’une catégorie enfant peut avoir elle même d’autres enfants).
rootColumnName: root_id est nécessaire si justement hasManyRoots est placé à true.

Le fichier fixture


category:
  cat1:        
    nom: categorie_1
    children: []
  cat2:
    nom: categorie_2
    children: 
      cat2a:        
        nom: categorie_2_a
        children:
          cat2a1:
            nom: categorie_2_1
            children: []
          cat2a2:
            nom: categorie_2_a_2
            children: []
          cat2a3:
            nom: categorie_2_a_3
            children: []
          cat2a4:
            nom: categorie_2_a_4
            children: []
      cat2b:
        nom: categorie_2_b
        children: 
          cat2b1:
            nom: categorie_2_b_1
            children: []
          cat2b2:
            nom: categorie_2_b_2
            children: []
          cat2b3:
            nom: categorie_2_b_3
            children: []
          cat2b4:
            nom: categorie_2_b_4
            children: []

La représentation d’un arbre en yml est très clair donc pas besoin de beaucoup d’explications. On peut juste préciser que children: [] signifie que la catégorie ne possède aucun enfant.

Il faut maintenant lancer la création de votre table et des classes des modèles en utilisant la formule magique:


symfony doctrine:build --all --and-load --no-confirmation

Configuration du formulaire

La première étape consiste à modifier le formulaire de base des catégories pour qu’il soit plus agréable et plus fonctionnel.
Pour cela modifiez le fichier CategoryForm.class.php comme ceci:


class CategoryForm extends BaseCategoryForm
{
  public function configure()
  {
    // cree un widget pour representer les categories parentes
    $this->setWidget('parent', new sfWidgetFormDoctrineChoiceNestedSet(array(
      'model'     => 'Category',
      'add_empty' => true,
    )));

    // si la categorie a un parent on en fait le choix par defaut
    if ($this->getObject()->getNode()->hasParent())
    {
      $this->setDefault('parent', $this->getObject()->getNode()->getParent()->getId());
    }

    // permet à l'utilisateur de ne changer que le nom et les parents d'une categorie
    $this->useFields(array(
      'name',
      'parent',
    ));
    // les labels des champs
    $this->widgetSchema->setLabels(array(
      'name'   => 'Category',
      'parent' => 'Parent category',
    ));
    // cree un validator qui evite qu'un enfant d'une categorie soit specifie comme etant son propre parent 
    $this->setValidator('parent', new sfValidatorDoctrineChoiceNestedSet(array(
      'required' => false,
      'model'    => 'Category',
      'node'     => $this->getObject(),
    )));
    $this->getValidator('parent')->setMessage('node', 'Une catégorie ne peut être le descendant d'elle même');
  }

  public function doSave($con = null)
  {
    // sauvegarde l'enregistrement
    parent::doSave($con);
    // si un parent a ete specifie, ajoute/déplace ce noeud pour etre l'enfant de ce parent
    if ($this->getValue('parent'))
    {
      $parent = Doctrine::getTable('Category')->findOneById($this->getValue('parent'));
      if ($this->isNew())
      {
        $this->getObject()->getNode()->insertAsLastChildOf($parent);
      }
      else
      {
        $this->getObject()->getNode()->moveAsLastChildOf($parent);
      }
    }
    // si aucun parent n'est specifie, ajoute/deplace ce noeud pour etre un nouveau noeud racine
    else
    {
      $categoryTree = Doctrine::getTable('Category')->getTree();
      if ($this->isNew())
      {
        $categoryTree->createRoot($this->getObject());
      }
      else
      {
        $this->getObject()->getNode()->makeRoot($this->getObject()->getId());
      }
    }
  }
}

Création du widget sfWidgetFormDoctrineChoiceNestedSet

Allez dans le dossier lib/widget (créez le s’il n’existe pas) et créez le fichier sfWidgetFormDoctrineChoiceNestedSet.class.php
Ajoutez-y le code suivant:


class sfWidgetFormDoctrineChoiceNestedSet extends sfWidgetFormDoctrineChoice
{
  public function getChoices()
  {
    $choices = array();
    if (false !== $this->getOption('add_empty'))
    {
      $choices[''] = true === $this->getOption('add_empty') ? '' : $this->getOption('add_empty');
    }

    if (null === $this->getOption('table_method'))
    {
      $query = null === $this->getOption('query') ? Doctrine_Core::getTable($this->getOption('model'))->createQuery() : $this->getOption('query');
      $query->addOrderBy('root_id asc')
            ->addOrderBy('lft asc');
      $objects = $query->execute();
    }
    else
    {
      $tableMethod = $this->getOption('table_method');
      $results = Doctrine_Core::getTable($this->getOption('model'))->$tableMethod();

      if ($results instanceof Doctrine_Query)
      {
        $objects = $results->execute();
      }
      else if ($results instanceof Doctrine_Collection)
      {
        $objects = $results;
      }
      else if ($results instanceof Doctrine_Record)
      {
        $objects = new Doctrine_Collection($this->getOption('model'));
        $objects[] = $results;
      }
      else
      {
        $objects = array();
      }
    }

    $method = $this->getOption('method');
    $keyMethod = $this->getOption('key_method');

    foreach ($objects as $object)
    {
      $choices[$object->$keyMethod()] = str_repeat(' ', ($object['level'] * 4)) . $object->$method();
    }

    return $choices;
  }
}

Création du validator sfValidatorDoctrineChoiceNestedSet

Allez dans le dossier lib/validator (créez le s’il n’existe pas) et créez le fichier sfValidatorDoctrineChoiceNestedSet.class.php
Ajoutez-y le code suivant:


class sfValidatorDoctrineChoiceNestedSet extends sfValidatorBase
{
  /**
   * Configures the validator.
   * Available options:
   *   model: The model class (required)
   *   node:   The node being moved (required)
   *
   * @see sfValidatorBase
   */
  protected function configure($options = array(), $messages = array())
  {
    $this->addRequiredOption('model');
    $this->addRequiredOption('node');

    $this->addMessage('node', 'A node cannot be set as a child of itself.');
  }

  protected function doClean($value)
  {
    if (isset($value) && !$value)
    {
      unset($value);
    }
    else
    {
      $targetNode = Doctrine::getTable($this->getOption('model'))->find($value)->getNode();
      if ($targetNode->isDescendantOfOrEqualTo($this->getOption('node')))
      {
        throw new sfValidatorError($this, 'node', array('value' => $value));
      }

      return $value;
    }
  }
}

Voila maintenant lorsque vous tentez d’ajouter ou modifier une catégorie vous aurez une jolie selectBox avec l’indentation qui va bien.

Surcharger la fonction delete

Il faut surcharger la fonction delete créée initialement par doctrine car, au lieu de simplement détruire le noeud demandé, elle doit également mettre à jour l’arbre.
Ouvrez le fichier apps/backend/modules/category/actions/actions.class.php et ajoutez-y le code suivant:


// /apps/frontend/modules/category/actions/actions.class.php

class CategoryActions extends autoCategoryActions
{
  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();

    $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject())));

    if ($this->getRoute()->getObject()->getNode()->delete())
    {
      $this->getUser()->setFlash('notice', 'The item was deleted successfully.');
    }

    $this->redirect('@category');
  }
  protected function executeBatchDelete(sfWebRequest $request)
  {
    $ids = $request->getParameter('ids');
    $records = Doctrine_Query::create()
      ->from('ChangeType')
      ->whereIn('id', $ids)
      ->execute();
    foreach ($records as $record)
    {
      $record->getNode()->delete();
    }

    $this->getUser()->setFlash('notice', 'The selected items have been deleted successfully.');
    $this->redirect('@change_type');
  }
}

On surcharge également la fonction executeBatchDelete afin que lorsqu’on supprime plusieurs catégories d’un coup l’arbre soit, là aussi, mis à jour.

Appliquer notre propre tri

Afin d’afficher nos catégories dans l’ordre nous avons besoin de trier nos résultats selon deux critères: root_id et lft. Malheureusement, le fichier generator.yml ne permet pas de trier sur deux colonnes. Il faut donc écraser la fonction de tri d’origine.
Pour cela ajoutez le code suivant au fichier actions.class.php


protected function addSortQuery($query)
{
  $query->addOrderBy('root_id asc');
  $query->addOrderBy('lft asc');
}

Le fichier generator.yml

On va maintenant définir les champs que l’on veut voir apparaître dans la liste. Ici nous ne voulons voir que le champ name


# /apps/frontend/modules/category/config/generator.yml

generator:
  class: sfDoctrineGenerator
  param:
    model_class:           Category
    theme:                 admin
    non_verbose_templates: true
    with_show:             false
    singular:              ~
    plural:                ~
    route_prefix:          category
    with_doctrine_route:   true
    actions_base_class:    sfActions

    config:
      actions: ~
      fields:  ~
      list:
        display: [=name]
      filter:  ~
      form:    ~
      edit:    ~
      new:     ~

Modifier le template de l’admin

Il faut maintenant modifier le template de base proposé par l’admin generator.
Pour cela, créez le fichier called _list_td_tabular.php et placez-le dans le dossier apps/backend/modules/templates.
Ajoutez-y le code suivant:


<?php use_stylesheet('nested-set.css') ?>
<td class="sf_admin_text sf_admin_list_td_name nested_set">
  <span class="<?php echo $category->getNode()->isLeaf() ? 'leaf' : 'node' ?>" style="<?php echo 'margin-left: ' . ($category['level'] * 1.3) . 'em' ?>">
    <?php echo link_to($category->getName(), 'category_edit', $category) ?>
  </span>
</td>

La fonction isLeaf est implantée par doctrine et permet de déterminer si le noeud en question possède des enfants ou pas. On applique donc la classe node ou leaf en conséquence.

le css

Via la fonction use_stylesheet on a appelé le fichier nested-set.css.
Il faut donc le créer et le placer dans le dossier web/css/


.nested_set span {
  background-position: center left;
  background-repeat: no-repeat;
  padding: .2em 0 .2em 1.5em;
}

.nested_set span.leaf {
  background-image: url('/images/icons/tree-leaf.png');
}

.nested_set span.node {
  background-image: url('/images/icons/tree-node.png');
}

Il ne vous reste plus qu’à trouver deux images, une pour les catégories avec enfants tree-node.png l’autre pour celles sans enfant tree-leaf.png

Si vous voulez plus de détails cet article est (grandement) inspiré de celui-ci (en anglais)

A lire

gestion des arbres par représentation intervallaire
Manipuler les données hiérarchisées avec Doctrine

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *