Gestion de la paternité (Symfony 1.2, Doctrine)

24.02.2009  • Samuel Breton

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 :
https://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

  • Un backend symfony 1.2, doctrine
  • Comment créer un projet symfony 1.2 Doctrine :
    https://symfony.com/legacy
  • Comment créer une application backend :
    https://symfony.com/legacy
  • Tout ce post est basé sur le travail des gens de ce lien (en anglais) :
    redotheoffice.com

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

    getNbResults()): ?>

    $sort)) ?>

    getResults() as $i => $tree): $odd = fmod(++$i, 2) ? ‘odd’ : ‘even’ ?>getParent()->getId(); } ?>”> $tree, ‘helper’ => $helper)) ?> $tree)) ?> $tree, ‘helper’ => $helper)) ?>

    haveToPaginate()): ?> $pager)) ?> $pager->getNbResults()), $pager->getNbResults(), ‘sf_admin’) ?> haveToPaginate()): ?> $pager->getPage(), ‘%%nb_pages%%’ => $pager->getLastPage()), ‘sf_admin’) ?>

     

  • _list_footer.php

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

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

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

    
    
  • _list_td_tabular.php

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

      
    
    

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

    /* jQuery treeTable Plugin 2.2 - https://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:
    - https://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
    - https://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:
https://www.lexik.fr/nested/index.php


Sources :

 

Directeur conseil chez Spiriit
J'accompagne nos clients sur la mise en place de la stratégie, de l'architecture et dans la structuration du projet. J'interviens en amont des projets pour la planification et en aval sur la partie KPI / Performance.
Voir l’étude de cas
Lire l’article
Lire l’actualité
En savoir plus
En savoir plus
Voir le témoignage
Fermer