Gestion de la paternité (Symfony 1.2, Doctrine)

La gestion d’arbres en SQL se fait traditionnellement par une auto-jointure, avec le champ classique parent_id. Cette méthode est cependant très coûteuse quand il s’agit de faire des recherches dans cet arbre car il faut alors utiliser la récursivité.

Une autre méthode beaucoup plus puissante existe : la gestion intervallaire. Vous pouvez trouver une explication détaillée de cette méthode ici :
http://sqlpro.developpez.com/cours/arborescence/.

Nous allons voir dans cet article comment implémenter celle-ci dans un projet symfony 1.2 avec doctrine.

Pré-requis

Schéma

/config/doctrine/schema.yml

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

Module

Créez le module :

 symfony doctrine:generate-admin backend Tree --module=tree

Formulaire

Modifiez la classe de formulaire :

/lib/form/doctrine/TreeForm.class.php

widgetSchema['parent_id'] = new sfWidgetFormDoctrineChoice(array(
      'model' => 'tree',
      'add_empty' => '~ (object is at root level)',
      'order_by' => array('root_id, lft',''),
      'method' => 'getIndentedName'
      ));
    $this->validatorSchema['parent_id'] = new sfValidatorDoctrineChoice(array(
      'required' => false,
      'model' => 'tree'
      ));
    $this->setDefault('parent_id', $this->object->getParentId());
    $this->widgetSchema->setLabel('parent_id', 'Child of');
 
  }
 
  public function updateParentIdColumn($parentId)
  {
    $this->parentId = $parentId;
    // further action is handled in the save() method
  }
 
  protected function doSave($con = null)
  {
    parent::doSave($con);
 
    $node = $this->object->getNode();
 
    if ($this->parentId != $this->object->getParentId() || !$node->isValidNode())
    {
      if (empty($this->parentId))
      {
        //save as a root
        if ($node->isValidNode())
        {
          $node->makeRoot($this->object['id']);
          $this->object->save($con);
        }
        else
        {
          $this->object->getTable()->getTree()->createRoot($this->object); //calls $this->object->save internally
        }
      }
      else
      {
        //form validation ensures an existing ID for $this->parentId
        $parent = $this->object->getTable()->find($this->parentId);
        $method = ($node->isValidNode() ? 'move' : 'insert') . 'AsFirstChildOf';
        $node->$method($parent); //calls $this->object->save internally
      }
    }
  }
}

Modèle

Modifiez la classe du modèle :

/lib/model/doctrine/Tree.class.php

getIndentedName());
  }
  public function getParentId()
  {
    if (!$this->getNode()->isValidNode() || $this->getNode()->isRoot())
    {
      return null;
    }
    $parent = $this->getNode()->getParent();
    return $parent['id'];
  }
  public function getIndentedName()
  {
    return str_repeat('- ',$this['level']).$this['name'];
  }
}

Actions

Modifier la classe action du module :

/apps/backend/modules/tree/actions

addOrderBy('root_id, lft');
  }
 
  public function executeBatch(sfWebRequest $request)
  {
    if ("batchOrder" == $request->getParameter('batch_action'))
    {
      return $this->executeBatchOrder($request);
    }
 
    parent::executeBatch($request);
  }
 
  public function executeBatchOrder(sfWebRequest $request)
  {
    $newparent = $request->getParameter('newparent');
 
    //manually validate newparent parameter
 
    //make list of all ids
    $ids = array();
    foreach ($newparent as $key => $val)
    {
      $ids[$key] = true;
      if (!empty($val))
        $ids[$val] = true;
    }
    $ids = array_keys($ids);
 
    //validate if all id's exist
    $validator = new sfValidatorDoctrineChoiceMany(array('model' => 'Tree'));
    try
    {
      // validate ids
      $ids = $validator->clean($ids);
 
      // the id's validate, now update the tree
      $count = 0;
      $flash = "";
 
      foreach ($newparent as $id => $parentId)
      {
        if (!empty($parentId))
        {
          $node = Doctrine::getTable('Tree')->find($id);
          $parent = Doctrine::getTable('Tree')->find($parentId);
 
          if (!$parent->getNode()->isDescendantOfOrEqualTo($node))
          {
            $node->getNode()->moveAsFirstChildOf($parent);
            $node->save();
 
            $count++;
 
            $flash .= "
Moved '".$node['name']."' under '".$parent['name']."'.";
          }
        }
      }
 
      if ($count > 0)
      {
        $this->getUser()->setFlash('notice', sprintf("Tree order updated, moved %s item%s:".$flash, $count, ($count > 1 ? 's' : '')));
      }
      else
      {
        $this->getUser()->setFlash('error', "You must at least move one item to update the tree order");
      }
    }
    catch (sfValidatorError $e)
    {
      $this->getUser()->setFlash('error', 'Cannot update the tree order, maybe some item are deleted, try again');
    }
 
    $this->redirect('@tree');
  }
 
  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();
 
    $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject())));
 
    $object = $this->getRoute()->getObject();
    if ($object->getNode()->isValidNode())
    {
      $object->getNode()->delete();
    }
    else
    {
      $object->delete();
    }
 
    $this->getUser()->setFlash('notice', 'The item was deleted successfully.');
 
    $this->redirect('@tree');
  }
 
  public function executeListNew(sfWebRequest $request)
  {
    $this->executeNew($request);
    $this->form->setDefault('parent_id', $request->getParameter('id'));
    $this->setTemplate('edit');
  }
 
  protected function processForm(sfWebRequest $request, sfForm $form)
  {
    $form->bind($request->getParameter($form->getName()), $request->getFiles($form->getName()));
    if ($form->isValid())
    {
      $this->getUser()->setFlash('notice', $form->getObject()->isNew() ? 'The item was created successfully.' : 'The item was updated successfully.');
 
      $tree = $form->save();
 
      $this->dispatcher->notify(new sfEvent($this, 'admin.save_object', array('object' => $tree)));
 
      if ($request->hasParameter('_save_and_add'))
      {
        $this->getUser()->setFlash('notice', $this->getUser()->getFlash('notice').' You can add another one below.');
 
        //$this->redirect('@tree_new');
      }
      else
      {
        //$this->redirect('@tree_edit?id='.$tree['id']);
      }
    }
    else
    {
      $this->getUser()->setFlash('error', 'The item has not been saved due to some errors.');
    }
  }
}

Generator

Modifiez le fichier :

/apps/backend/modules/tree/config/generator.yml

generator:
  class: sfDoctrineGenerator
  param:
    model_class:           Tree
    theme:                 admin
    non_verbose_templates: true
    with_show:             false
    singular:              ~
    plural:                ~
    route_prefix:          tree_tree
    with_doctrine_route:     1
 
    config:
      actions: ~
      fields:  ~
      list:
        title:   Gestions des catégories
        max_per_page: 999999
        batch_actions:
          order:
            label: Update tree order
          _delete: ~
        object_actions:
          new:
            label: Add Child
          _edit:    ~
          _delete:  ~
        actions:
          _new:
            label: Add Root
      filter:  ~
      form:    ~
      edit:
        title: Editing Categorie "%%name%%"
      new:     ~

Templates

    Créez les fichiers suivant :

  • _list.php

    /apps/backend/modules/tree/templates_list.php

    <div class="sf_admin_list">
      getNbResults()): ?>
    <table id="main_list" border="0" cellspacing="0">
    <thead>
    <tr>
    <th id="sf_admin_list_batch_actions">
    <input id="sf_admin_list_batch_checkbox" onclick="checkAll();" type="checkbox" /></th>
    $sort)) ?>
    <th id="sf_admin_list_th_actions"></th>
    </tr>
    </thead>
    <tfoot>
    <tr>
    <th colspan="8">
                haveToPaginate()): ?>
                   $pager)) ?>
     
                 $pager->getNbResults()), $pager->getNbResults(), 'sf_admin') ?>
                haveToPaginate()): ?>
                   $pager->getPage(), '%%nb_pages%%' => $pager->getLastPage()), 'sf_admin') ?></th>
    </tr>
    </tfoot>
    <tbody>
            getResults() as $i => $tree): $odd = fmod(++$i, 2) ? 'odd' : 'even' ?>
    <tr id="node-<?php echo $tree['id'] ?>" class="sf_admin_row <?php echo $odd ?><?php // insert hierarchical info $node = $tree->getNode(); if ($node->isValidNode() && $node->hasParent()) { echo">getParent()->getId();
              }
              ?>">
                 $tree, 'helper' => $helper)) ?>
                 $tree)) ?>
                 $tree, 'helper' => $helper)) ?></tr>
    </tbody></table>
    </div>
    <script type="text/javascript"><!--mce:0--></script>
  • _list_footer.php

    /apps/backend/modules/tree/templates_list_footer.php

    <em>After changing the order of the tree, the new order should be saved. This is currently implemented
    as a batch action, so please choose <strong>Update tree order</strong> from the 'Choose an action' dropdown
    and click on 'Go' to save the new order.</em>
  • _list_td_batch_actions.php

    /apps/backend/modules/tree/templates_list_td_batch_actions.php

    <input class="sf_admin_batch_checkbox" name="ids[]" type="checkbox" value="<?php echo $tree->getPrimaryKey() ?>" />
    <input id="select_node-<?php echo $tree->getPrimaryKey() ?>" name="newparent[<?php echo $tree->getPrimaryKey() ?>]" type="hidden" />
  • _list_td_tabular.php

    /apps/backend/modules/tree/templates/_list_td_tabular.php

      <span class="<?php echo $tree->getNode()->isLeaf() ? 'file' : 'folder' ?>">
     
      </span>

CSS/JS

  • CSSCréez ce fichier :

    /web/css/jQuery.treeTable.css

    /* jQuery TreeTable Core 2.0 stylesheet
     *
     * This file contains styles that are used to display the tree table. Each tree
     * table is assigned the +treeTable+ class.
     * ========================================================================= */
     
    /* jquery.treeTable.collapsible
     * ------------------------------------------------------------------------- */
    .treeTable tr td .expander {
    	background-position: left center;
    	background-repeat: no-repeat;
    	cursor: pointer;
    	padding: 0;
    	zoom: 1; /* IE7 Hack */
    }
     
    .treeTable tr.collapsed td .expander {
    	background-image: url(../images/toggle-expand-dark.png);
    }
     
    .treeTable tr.expanded td .expander {
    	background-image: url(../images/toggle-collapse-dark.png);
    }
     
    /* jquery.treeTable.sortable
     * ------------------------------------------------------------------------- */
    .treeTable tr.selected, .treeTable tr.accept {
    	background-color: #ccccff !important;
    	color: #fff !important;
    } 
     
    .treeTable tr.collapsed.selected td .expander, .treeTable tr.collapsed.accept td .expander {
    	background-image: url(../images/toggle-expand-light.png);
    }
     
    .treeTable tr.expanded.selected td .expander, .treeTable tr.expanded.accept td .expander {
    	background-image: url(../images/toggle-collapse-light.png);
    }
     
    .treeTable .ui-draggable-dragging {
    	color: #000;
    	z-index: 1;
    }

    Créez ce fichier :

    /web/css/main.css

    table span {
      background-position: center left;
      background-repeat: no-repeat;
      padding: .2em 0 .2em 1.5em;
    }
     
    table span.file {
      background-image: url(../images/page_white_text.png);
    }
     
    table span.folder {
      background-image: url(../images/folder.png);
    }
  • JavascriptCréez ce fichier :

    /web/js/jQuery.treeTable.js

    ?View Code JAVASCRIPT
    /* jQuery treeTable Plugin 2.2 - http://ludo.cubicphuse.nl/jquery-plugins/treeTable/ */
    (function($) {
      // Helps to make options available to all functions
      // TODO: This gives problems when there are both expandable and non-expandable
      // trees on a page. The options shouldn't be global to all these instances!
      var options;
     
      $.fn.treeTable = function(opts) {
        options = $.extend({}, $.fn.treeTable.defaults, opts);
     
        return this.each(function() {
          $(this).addClass("treeTable").find("tbody tr").each(function() {
            // Initialize root nodes only whenever possible
            if(!options.expandable || $(this)[0].className.search("child-of-") == -1) {
              initialize($(this));
            }
          });
        });
      };
     
      $.fn.treeTable.defaults = {
        childPrefix: "child-of-",
        expandable: true,
        indent: 19,
        initialState: "collapsed",
        treeColumn: 0
      };
     
      // Recursively hide all node's children in a tree
      $.fn.collapse = function() {
        $(this).addClass("collapsed");
     
        childrenOf($(this)).each(function() {
          initialize($(this));
     
          if(!$(this).hasClass("collapsed")) {
            $(this).collapse();
          }
     
          $(this).hide();
        });
     
        return this;
      };
     
      // Recursively show all node's children in a tree
      $.fn.expand = function() {
        $(this).removeClass("collapsed").addClass("expanded");
     
        childrenOf($(this)).each(function() {
          initialize($(this));
     
          if($(this).is(".expanded.parent")) {
            $(this).expand();
          }
     
          $(this).show();
        });
     
        return this;
      };
     
      // Add an entire branch to +destination+
      $.fn.appendBranchTo = function(destination) {
        var node = $(this);
        var parent = parentOf(node);
     
        var ancestorNames = $.map(ancestorsOf($(destination)), function(a) { return a.id; });
     
        // Conditions:
        // 1: +node+ should not be inserted in a location in a branch if this would
        //    result in +node+ being an ancestor of itself.
        // 2: +node+ should not have a parent OR the destination should not be the
        //    same as +node+'s current parent (this last condition prevents +node+
        //    from being moved to the same location where it already is).
        // 3: +node+ should not be inserted as a child of +node+ itself.
        if($.inArray(node[0].id, ancestorNames) == -1 && (!parent || (destination.id != parent[0].id)) && destination.id != node[0].id) {
          indent(node, ancestorsOf(node).length * options.indent * -1); // Remove indentation
     
          if(parent) { node.removeClass(options.childPrefix + parent[0].id); }
     
          node.addClass(options.childPrefix + destination.id);
          move(node, destination); // Recursively move nodes to new location
          indent(node, ancestorsOf(node).length * options.indent);
        }
     
        return this;
      };
     
      // Add reverse() function from JS Arrays
      $.fn.reverse = function() {
        return this.pushStack(this.get().reverse(), arguments);
      };
     
      // Toggle an entire branch
      $.fn.toggleBranch = function() {
        if($(this).hasClass("collapsed")) {
          $(this).expand();
        } else {
          $(this).removeClass("expanded").collapse();
        }
     
        return this;
      };
     
      // === Private functions
     
      function ancestorsOf(node) {
        var ancestors = [];
        while(node = parentOf(node)) {
          ancestors[ancestors.length] = node[0];
        }
        return ancestors;
      };
     
      function childrenOf(node) {
        return $("table.treeTable tbody tr." + options.childPrefix + node[0].id);
      };
     
      function indent(node, value) {
        var cell = $(node.children("td")[options.treeColumn]);
        var padding = parseInt(cell.css("padding-left"), 10) + value;
     
        cell.css("padding-left", + padding + "px");
     
        childrenOf(node).each(function() {
          indent($(this), value);
        });
      };
     
      function initialize(node) {
        if(!node.hasClass("initialized")) {
          node.addClass("initialized");
     
          var childNodes = childrenOf(node);
     
          if(!node.hasClass("parent") && childNodes.length > 0) {
            node.addClass("parent");
          }
     
          if(node.hasClass("parent")) {
            var cell = $(node.children("td")[options.treeColumn]);
            var padding = parseInt(cell.css("padding-left"), 10) + options.indent;
     
            childNodes.each(function() {
              $($(this).children("td")[options.treeColumn]).css("padding-left", padding + "px");
            });
     
            if(options.expandable) {
              cell.prepend('');
              $(cell[0].firstChild).click(function() { node.toggleBranch(); });
     
              // Check for a class set explicitly by the user, otherwise set the default class
              if(!(node.hasClass("expanded") || node.hasClass("collapsed"))) {
                node.addClass(options.initialState);
              }
     
              if(node.hasClass("collapsed")) {
                node.collapse();
              } else if (node.hasClass("expanded")) {
                node.expand();
              }
            }
          }
        }
      };
     
      function move(node, destination) {
        node.insertAfter(destination);
        childrenOf(node).reverse().each(function() { move($(this), node[0]); });
      };
     
      function parentOf(node) {
        var classNames = node[0].className.split(' ');
     
        for(key in classNames) {
          if(classNames[key].match("child-of-")) {
            return $("#" + classNames[key].substring(9));
          }
        }
      };
    })(jQuery);

view.yml

Modifiez ce fichier :

/apps/backend/config/view.yml

default:
  http_metas:
    content-type: text/html
 
  metas:
    #title:        symfony project
    #description:  symfony project
    #keywords:     symfony, project
    #language:     en
    #robots:       index, follow
 
  stylesheets:    [main.css, jQuery.treeTable.css]
 
  javascripts:
    - http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
    - http://ajax.googleapis.com/ajax/libs/jqueryui/1.5.3/jquery-ui.min.js
    - jquery.treeTable.js
 
  has_layout:     on
  layout:         layout

Routing

Il faut rajouter la règle de routage « tree » !!

/apps/backend/config/routing.yml

tree_tree:
  class: sfDoctrineRouteCollection
  options:
    model:               Tree
    module:              tree
    prefix_path:         tree
    column:              id
    with_wildcard_routes: true
tree:
  class: sfDoctrineRouteCollection
  options:
    model:               Tree
    module:              tree
    prefix_path:         tree
    column:              id
    with_wildcard_routes: true
# default rules
homepage:
  url:   /
  param: { module: default, action: index }
 
default_index:
  url:   /:module
  param: { action: index }
 
default:
  url:   /:module/:action/*

Clear Cache

On vide le cache :

symfony cc

Fixtures

Ajouter ce fichier :

/data/fixtures/data.yml

Tree:
  Tree_1:
    name: Couleurs
    root_id: '1'
    lft: '1'
    rgt: '8'
    level: '0'
  Tree_2:
    name: Bleu
    root_id: '1'
    lft: '6'
    rgt: '7'
    level: '1'
  Tree_3:
    name: Rouge
    root_id: '1'
    lft: '2'
    rgt: '3'
    level: '1'
  Tree_4:
    name: Vert
    root_id: '1'
    lft: '4'
    rgt: '5'
    level: '1'
  Tree_5:
    name: Langages
    root_id: '5'
    lft: '1'
    rgt: '14'
    level: '0'
  Tree_6:
    name: Serveur
    root_id: '5'
    lft: '8'
    rgt: '13'
    level: '1'
  Tree_7:
    name: Client
    root_id: '5'
    lft: '2'
    rgt: '7'
    level: '1'
  Tree_8:
    name: Javascript
    root_id: '5'
    lft: '5'
    rgt: '6'
    level: '2'
  Tree_9:
    name: ActionScript
    root_id: '5'
    lft: '3'
    rgt: '4'
    level: '2'
  Tree_10:
    name: PHP
    root_id: '5'
    lft: '11'
    rgt: '12'
    level: '2'
  Tree_11:
    name: ASP
    root_id: '5'
    lft: '9'
    rgt: '10'
    level: '2'

build-all-reload

symfony doctrine:build-all-reload

Les images :

A copier dans web/images

pagewhitetext.png page_white_text.png
pagewhitetext.png folder.png
toggle_collapse_dark.png toggle_collapse_dark.png
toggle_collapse_light.png toggle_collapse_light.png
toggle_expand_dark.png toggle_expand_dark.png
toggle_expand_light.png toggle_expand_light.png

Aperçu

Vous devriez avoir ça !


Je rajoute un lien pratique: un petit outil que nous avons fait pour pouvoir générer automatiquement tous les fichiers, donc plus besoin de suivre le post à la lettre, complétez le formulaire et télécharger vos fichiers créés:
http://www.lexik.fr/nested/index.php


Sources :

    

11 Responses to “Gestion de la paternité (Symfony 1.2, Doctrine)”

  1. Jérémy B. dit :

    Excellent article !
    Je l’ai bookmarké depuis un bout de temps mais je n’avais toujours pas eu le temps de venir déposer un commentaire.
    Ce post complète très bien celui de Redo the Office.

  2. thomas dit :

    voici un petit outil que je viens de créer pour faciliter la mise en place du nestedset et de jquery. Effectivement suivant le nom de son module et de son modele, il faut mettre le nez dans le code et modifier toutes les occurences. Plus la peine, nous générons les fichiers avec le nom de votre modèle!
    http://www.lexik.fr/nested/index.php

  3. BugsByte dit :

    Salut,

    Félicitation pour ce travail.

    Je cherche à faire la même chose avec Symfony/Propel et prototype/scriptaculous mais je n\’y parviens pas.

    Savez-vous où je peux trouver de l\’aide ?

  4. Manu dit :

    Très bon article !

    Ma question concerne l’outil de Thomas, pourquoi le champs affiché ne doit pas être un champs I18N ?

    D’avance merci :-)

  5. thomas dit :

    @Manu
    Bonjour et merci!
    le champs ne doit pas être I18N car dans le backend dans le listing nous avons rencontré des erreurs en listant un champs I18N. Donc pour simplifier et corriger facilement, on ne liste pas un champs I18N.
    Cependant avec les mises a jours symfony ce problème ne ressort peut être plus, à tester!

  6. arsenik dit :

    Super tuto !
    J’ai bien aimé ton générateur de fichiers car il manque quelques lignes de codes dans le post :)

    @BugsByte pour avec propel j’avais combiné le tree de chez dhmlgoodies

  7. Nicolas dit :

    Hello,

    It will be cool :
    - to optimize the number of requests ($tree->getNode() makes a request per line),
    - to add sortable behavior (like the menu management in Drupal),
    - and to update more than one change at the same time.

    Thanks for your webiste.

  8. Seb dit :

    Toujours aussi utile ce site !!!
    Je me suis ajouté 2 objects actions pour déplacer les enfants de même parent si ça peut être utile …
    http://404past-it.fr/721

    Par contre, je ne comprends pas l\’action Order ….

    Encore merci!
    Seb

  9. Thibault dit :

    Ouf ! Sacré travail. Qui m\’a fait gagner pas mal de temps, merci beaucoup.

    À quand un plugin ?

  10. Sean dit :

    Merci pour tout votre travail, mais la plupart des blocs de code semble corrompu, avec des lignes manquantes ou de parties manquantes. Je pense que c’est un problème avec WP-CODEBOX Plugin que j’ai vu le même problème sur certains autres blogs récemment. Pourriez-vous supprimer les balises de code de sorte que nous pouvons voir tout le code?

  11. azerty dit :

    Attention, pour fonctionner avec la version 1.4 de Symfony, une petite modification doit être réalisée sur le fichier action.class.php au niveau de la ligne 37 :

    Ancienne ligne :
    $validator = new sfValidatorDoctrineChoiceMany(array(’model’ => ‘Tree’));

    Nouvelle ligne :
    $validator = new sfValidatorDoctrineChoice(array(’model’ => ‘Tree’, ‘multiple’ => true));

Leave a Reply

Security Code: