Archive pour la catégorie ‘Zend_Db’

Auto-Jointure

Mardi 12 février 2008

Dans mon article « Ajouter un champ calculé dans une table. » Je parlais d’une méthode pour ajouter des champs dans les objets row issue d’une table qui ne les contient pas base.
La version 1.5 de ZF a introduit une nouvelle classe qui facilite ce genre de manipulation.

L’Auto Jointure simple

Cette petite nouveauté introduit de façon claire les select qui sous-tend la classe Zend_Db_Table. Pour voir comment cette introduction change la donne je me suis posé le problème de l’auto jointure. J’ai une table principale et une où plusieurs table de références.
Le but de l’autojointure et de remonter avec les éléments de la table les valeurs de références. Par exemple les éléments de ma table principale sont typés, il contient un id sur la table de référence des types. Je veux lorsque je lis un enregistrement pouvoir remonter le label de sont type sans avoir systématiquement à faire une deuxième requête.
Les données complémentaires étant des données de référence leur valeur ne change pas (pas lorsqu’on manipule un élément de la table principale) je ne poserais donc pas ici le problème de l’écriture en base.

Zend_Db_Table_Select

Voyons comment fonction les classes de Zend_Db lorsque nous lisons dans la table. L’appel des méthodes fetch, fetchCols, fetchRow, fetchAll, et find font tous appels à _fetch. Dans les versions antérieures à la 1.5 la méthode _fetch recevais les paramètres de la requête. Where et autres order ou limit. La méthode _fetch fabriquait donc un select avec $this->db->select() et y appliquait les clauses adéquates.
Avec la version 1.5 les fonctions suscitées vont-elles-même créer l’objet select et lui ajouter les clauses nécessaires. Pour éviter que chaque fonction réinvente la roue elles ne vont pas créer un simple select mais un Zend_Db_Table_Select dont la base est simplement
Select * From tablename. Pour cela une nouvelle méthode à été ajouté à Zend_Db_Table : select il est donc possible de demander à la table un select préconfiguré pour le manipuler à sa sauce. Nous allons donc nous arranger pour que ce select ne soit pas un simple Zend_Db_Table_Select mais pour qu’il ajoute automatiquement une ou plusieurs jointures à la table.

Fast_Db_Table

Une classe table acceptant l’autojointure. Notre but est de définir une classe comme Zend_Db_Table_Abstract que nous pourrons dériver pour la mapper sur les éléments de la base. On va donc introduire un membre qui listera les jointures à effectuer dans les requêtes. Et tant que nous y sommes nous allons ajouter des restrictions automatique (clauses where ajouté systématiquement)

 /**
 * Definition de base d'une table Fast
 * elle étend la classe Zend_Table et lui adjoint un classe spécifique pour les enregistrement
 * ainsi que les méthode courantes d'accès au données
 *
 * @see  Zend_Db_Table
 * @see  Fast_Exception_Db
 * @see  Fast_Db_Row
 * @author Jean-Yves Terrien
 */
Class Fast_Db_Table extends Zend_Db_Table_Abstract {

   const FAST_RESTRICT = 'fast_restrict';
   const FAST_AUTOJOIN = 'fast_autojoin';
   /**
   * Classname for select , Zend_Db_Table_Select,...
   *
   * @var string
   */
   protected $_selectClass = 'Fast_Db_Table_Select';
   /**
   * Restriction for query
   *
   * @var string
   */
   protected $_restrict = null;
   /**
   * Auto Joined table for query
   *
   * @var string
   */
   protected $_autojoin = NULL;
   /**
    * Returns an instance of a Zend_Db_Table_Select object.
    *
    * @return Zend_Db_Table_Select
    */
   public function select()
   {
      if ('Zend_Db_Table_Select' == $this->_selectClass) {
         $select = parent::select();
      } else {
         Zend_Loader::loadClass($this->_selectClass);
         $select = new $this->_selectClass($this);
      }
      return $select;
   }
    /**
     * Returns table information.
     *
     * @return array
     */
    public function info()
    {
        $info = parent::info();
        $info[self::FAST_RESTRICT] = $this->_restrict;
        $info[self::FAST_AUTOJOIN] = $this->_autojoin;
        return $info;
    }
}

Voilà la base de notre classe on va lui indiquer la classe Select à utiliser (il faudra qu’elle dérive de Zend_Db_Table_Select) cela permettra de la surcharger. On redéfinit la méthode Select() pour tenir compte de notre classe Select et on redéfini la méthode info pour que la classes Select connaisse les jointures à faire.
La définition de la requête étant faite dans la classe Select c’est tout pour la classe Table.

Fast_Db_Table_Select

Un select pour table à auto jointure.
Il est un peu étonnant de voir que la classe Zend_Db_Table_Select ne crée pas la partie from de la table par défaut. Elle ne le fait qu’au moment de la transformation en chaine. Du coup si on tente de faire le join avant on obtient une exception. Il faut donc respecter cette mécanique pour nous insérer au mieux dans ZF

 class Fast_Db_Table_Select extends Zend_Db_Table_Select
{
   private $_autojoined = false;
   private $_useRestrict = true;
   /**
   * Performs a validation on the select query before passing back to the parent class.
   * Ensures that only columns from the primary Zend_Db_Table are returned in the result.
   *
   * @return string This object as a SELECT string.
   */
   public function __toString()
   {
      if (!$this->_autojoined) {
         $this->_autojoined = true;

         $fields  = $this->getPart(Zend_Db_Table_Select::COLUMNS);
         $primary = $this->_info[Zend_Db_Table_Abstract::NAME];
         $schema  = $this->_info[Zend_Db_Table_Abstract::SCHEMA];

         // If no fields are specified we assume all fields from primary table
         if (!count($fields)) {
            $this->from($primary, '*', $schema);
            $fields = $this->getPart(Zend_Db_Table_Select::COLUMNS);
         }

         if ($this->_useRestrict) {
            if (is_string($this->_info[Fast_Db_Table::FAST_RESTRICT])) {
               $restricts[] = $this->_info[Fast_Db_Table::FAST_RESTRICT];
            } elseif (is_array($this->_info[Fast_Db_Table::FAST_RESTRICT])) {
               $restricts = $this->_info[Fast_Db_Table::FAST_RESTRICT];
            } else {
               $restricts = array();
            }
            foreach ($restricts as $restrict) {
               $this->where($restrict);
            }
         }
         if (is_array($this->_info[Fast_Db_Table::FAST_AUTOJOIN])) {
            $this->setIntegrityCheck(false);
            foreach ($this->_info[Fast_Db_Table::FAST_AUTOJOIN] as $join) {
               if (is_array($join)) {
                  $this->join($join['table'], $join['on'], $join['fields']);
               }
            }
         }
      }
      return parent::__toString();
   }
   /**
      @function setRestrict()
   	@param boolean $restrict Use restriction for this select
      @return Fast_Db_Table_Select Description
   */
   function setRestrict($restrict) {
      $this->_useRestrict = $restrict;
      return $this;
   } // end function setRestrict
}

Le premier membre private $_autojoined indique que la jointure à déjà été faite. Vu que nous l’ajoutons lors de la transformation en chaine il ne faudrait pas que le soit plusieurs fois. Un simple echo sur l’objet appelle __toString()
Le second private $_useRestrict = true indique que nous devons ou pas utiliser les clauses restrictives
La méthode setRestrict permet de changer ce mode.
La méthode __toString est semblable à celle de sa classe parente elle ne fait que parcourir le tableau d’auto jointure pour ajouter les clauses join.

Une utilisation

La classe user. Nous allons remonter le champ label du profil en même temps que l’utilisateur.

 Class Adm_Model_User_Table extends Fast_Db_Table {
   /**
   * The table name.
   *
   * @var array
   */
   protected $_name = 'user';

   /**
   * Classname for row
   *
   * @var string
   */
   protected $_rowClass = 'Adm_Model_User_Row';
   /**
   * Auto Joined table for query
   *
   * @var string
   */
   protected $_autojoin = array(
      array('table' => 'profile',
            'on' =>  'profile.prf_id = user.prf_id',
            'fields' => array('prf_label')
      )
   );
   /**
   * Restriction for query
   *
   * @var string
   */
   protected $_restrict = array('profile.prf_valid = 1');
}

Lorsque nous utiliserons cette classe la requête générée par défaut sera

Select
   user.*,
   profile.prf_label
FROM user
INNER JOIN profile ON (profile.prf_id = user.prf_id)
WHERE profile.prf_valid = 1

Conclusion

Zend_Db_Table_Select apporte une ouverture nouvelle pour adapter au mieux à ses besoins le mapping objet de ZF. Les classes ci-dessus ne sont que des premières versions. Elles sont grandement améliorables. Par exemple pour gérer les jointures plus complexes comme left rigth mais aussi avec des clauses de recoupement etc.

Ajouter un champ calculé dans une table.

Vendredi 1 février 2008

Il arrive parfois qu’il soit intéressant de véhiculer un champ dans un objet de mapping qui n’est pas conservé en base. C’est le cas entre autre des champs calculés. Je veux par exemple un objet facture. Lorsque je manipule ma facture un élément important est le total de la facture. Mais une facture en elle-même est composée de champs qui lui sont propres et de lignes de facturations. Chaque ligne véhicule une partie du total de la facture. Lorsque je manipule la facture je n’ai pas nécessairement besoin de ses lignes de facturations. Par exemple lorsque je vérifie que le montant payé et bien le bon. Inutile de remonter toutes les lignes seul le total m’intéresse. Je peux alors décider de garder en base le total. Il me faudra alors veiller à ce que ce total soit tenu à jour en adéquation avec mes lignes de facturations. Je peux aussi décider de ne pas le garder en base. Mais alors il me faudra le calculer et donc faire deux accès à la base pour obtenir ma facture (sans ses lignes mais avec) son total

Ajouter un champ à un objet de mapping

Une solution consiste à calculer ce champ lors de la lecture en base et de le garder dans un coin. Si je le mets directement dans mon objet de mapping je vais me heurter à quelques difficultés. Par exemple si j’enregistre cet objet l’ORM de Zend va au mieux supprimer le champ, au pire lever une exception et il aura raison. Il ne sait qu’en faire. Le but de cet article est de voir les points à lever pour arriver à cette solution.

la table facture

La toute première étape consiste à crée un classe pour la table facture

Class Model_Facture_Table extends Zend_Db_Table_Abstract {
   protected $_name = 'facture';
   protected $_rowClass = ' Model_Facture_Row';
   public function __construct($config = array())
   {
      parent::__construct($config);
	$this->_cols[] = 'fac_total';
   }
}

Et d’y ajouter une colonne. Ainsi lorsque je sortirais ou entrerait une facture dans ma table le champ fac_total ne sera pas inconnu.
Mais il va falloir aller un peu plus loin si on ne veut pas se retrouver avec des Exception de partout. La première étape passe par le calcul de ce champ. Donc lorsqu’on lit un enregistrement dans la base. ZF est ainsi fait que quelque soit la façon dont vous interrogez votre table il passe toujours par la même méthode. Sauf évidement si vous écrivez vous-même une requête. La méthode qui définit la requête à effectuer sur la base pour lire un ou plusieurs enregistrements s’appelle _fetch. Il nous faut donc la modifier pour obtenir le résultat que nous cherchons. Ainsi toute lecture prendra en compte notre modification. Pour bien comprendre ce que fait cette méthode il suffit de se pencher sur le fonctionnement d’une Zend_Db_Table. De façon générale une Zend_Db_Table c’est

SELECT * FROM tableName ;

Les autres méthodes de recherche ne font qu’ajouter des clauses WHERE ORDER ETC. le but de la méthode _fetch est de construire cette requête.
Moi, je voudrais à la place

SELECT
   facture .*,
   SUM(lig_prix*lig_qte) AS fac_total
FROM facture
INNER JOIN lignes USING (fac_id)
GROUP BY fac_id;

modifier la méthode _fetch

Tout d’abord voyons comment est faite la méthode de Zend

    /**
     * Support method for fetching rows.
     *
     * @param  string|array $where  OPTIONAL An SQL WHERE clause.
     * @param  string|array $order  OPTIONAL An SQL ORDER clause.
     * @param  int          $count  OPTIONAL An SQL LIMIT count.
     * @param  int          $offset OPTIONAL An SQL LIMIT offset.
     * @return array The row results, in FETCH_ASSOC mode.
     */
    protected function _fetch($where = null,
                              $order = null,
                              $count = null,
                              $offset = null)
    {
        // selection tool
        $select = $this->_db->select();

        // the FROM clause
        $select->from($this->_name, $this->_cols, $this->_schema);

        // the WHERE clause
        $where = (array) $where;
        foreach ($where as $key => $val) {
            // is $key an int?
            if (is_int($key)) {
                // $val is the full condition
                $select->where($val);
            } else {
                // $key is the condition with placeholder,
                // and $val is quoted into the condition
                $select->where($key, $val);
            }
        }

        // the ORDER clause
        if (!is_array($order)) {
            $order = array($order);
        }
        foreach ($order as $val) {
            $select->order($val);
        }

        // the LIMIT clause
        $select->limit($count, $offset);
        // return the results
        $stmt = $this->_db->query($select);
        $data = $stmt->fetchAll(Zend_Db::FETCH_ASSOC);
        return $data;
    }

Cette méthode est, un peu, longue mais au final par très complexe. On voit vite que notre SELECT * est à la ligne

$select->from($this->_name, $this->_cols, $this->_schema);

Et que c’est là qu’il faut intervenir. En effet le reste n’est que l’ajout de clause diverse.
Remplaçons donc

$cols = $this->_cols;
unset($cols[array_search('fac_total',$cols)]);
$select->from($this->_name, $cols, $this->_schema)
->join('lignes',’ligne.fac_id = facture.fac_id’, array('fac_total' =>Zend_Db_Exp('SUM(lig_prix * lig_qte)')))
->group('fac_id');

Nous avons maintenant une table qui lit des factures avec leur total.

L’écriture

Tant que nous ne faisons que lire dans la table avec cet objet, nous n’auront pas de problème.
Mais si nous tentons un update ou un insert nous allons avoir un problème. En effet nous allons essayer de mettre à jour dans la base un champ qui n’y est pas. Il nous faut donc retirer ce champ de l’objet. Avant l’enregistrement.

   public function insert(array $data) {
      unset($data[‘fac_total’]);
      parent::insert($data);
   }

Cela n’est en fait pas bien compliqué. La méthode insert va en accord avec la liste des colonnes de la table tenter d’ajouter l’enregistrement avec tous les champs présents dans $this->_cols si un champ de data n’est pas dans la liste, il sera supprimé, mais fac_total y est puisque nous l’avons ajouté. Mais ce champ n’est pas dans la table le moteur SQL va donc rejeter la requête. Il suffit donc de supprimer ce champ des données.
La méthode update à un comportement équivalent mais légèrement différent. En ce sens que update va tenter de mettre à jour même les champs qui ne son pas présent dans $data. Si je retire simplement le champ fac_total la méthode update tentera de le mettre à null dans la base. Il faut donc retirer le champ des données mais aussi de la liste des colonnes. Et le restituer ensuite car sinon notre objet table sera incohérent.

    public function update(array $data, $where)
    {
      unset($this->_cols[array_search(‘fac_total’,$this->_cols)]);
      unset($data[‘fac_total’]);
      $res = parent::update($data, $where);
      $this->_cols[] = ‘fac_total’;
      return $res;
    }

Il est ainsi possible d’ajouter de nombreux champs dans un objet table qui ne serons pas stockés en base. Il est assez simple de généraliser cette méthode. En se basant sur la description des relations dans les Zend_Db_Table on peut imaginer une classe abstraite qui contiendrait un tableau des autoJoinnedTable et qui implémenterait ce principe.

Un exemple généralisé

Pour ma part je l’utilise dans un tout autre contexte. J’utilise des tables hiérarchiques en POO il est simple et efficace d’utiliser une référence sur l’objet parent pour constituer une hiérarchie. Mais cette approche n’est pas performante dans une base de données. Une représentation intervallaire l’est bien mieux. Je vous conseille de lire les articles sur le sujet.
Dans le cas qui m’intéresse, j’ais donc des tables qui on un id numérique unique comme clef et des données. Le principe de la représentation intervallaire consiste à ajouter une borne droite et une borne gauche, j’ai aussi très souvent besoin de niveau hiérarchique relatif. Par exemple tous les nœuds au rand N+1, N+2 et N+3 d’un nœud donné. J’ajoute donc dans ma table un champ level.
Mais du côté PHP il est plus simple d’utiliser une relation père fils que la notion d’intervalle. Ma classe va donc masquer la représentation intervallaire à PHP. Elle va gérer elle-même les transactions nécessaires pour maintenir à jours les bornes des éléments de la table.

Zend_Loader::loadClass('Zend_Db_Table');

Class Fast_Db_Hierarchical extends Zend _Db_Table {

   /**
   * left field name in table
   *
   * @var string
   */
   protected $_left = NULL;

   /**
   * right field name in table
   *
   * @var string
   */
   protected $_right = NULL;

   /**
   * level field name in table
   *
   * @var string
   */
   protected $_level = NULL;

   /**
   * virtual field name used has id of parent
   *
   * @var string
   */
   protected $_parent = NULL;

   public function __construct($config = array())
   {
      parent::__construct($config);
      if (null == $this->_left)
         throw new Fast_Exception_Db(Fast_Exception_Db::UNDEFINED_LEFT_KEY);
      if (null == $this->_right)
         throw new Fast_Exception_Db(Fast_Exception_Db::UNDEFINED_RIGHT_KEY);
      if (null == $this->_level)
         throw new Fast_Exception_Db(Fast_Exception_Db::UNDEFINED_LEVEL_KEY);
      if (null == $this->_parent)
         throw new Fast_Exception_Db(Fast_Exception_Db::UNDEFINED_PARENT);
      $this->_cols[] = $this->_parent;
   }

   public function getById($id) {
      $rows = $this->find($id);
      if ($rows) {
         return $rows->current();
      }
      return false;
   }

   public function deleteById($id) {
      if ($id == 1) return false; // on ne peut supprimer la racine

      $this->_db->beginTransaction();
      $parent = $this->_db->select();
      $parent->from($this->_name, array('delete_left' => $this->_left,
                                        'delete_right' => $this->_right))
             ->where($this->_primary[1].' = :_deleteId');
      $statement = $this->_db->prepare($parent);
      $statement->execute(array('_deleteId' => $id));
      list($deleteLeft, $deleteRight) = array_values($statement->fetch());
      $res = false;
      if ($deleteLeft) {
         $row = $this->getById($id);
         $res = $row->delete();

         if ($res) {
            $statement = $this->_db->prepare(
               'UPDATE '.$this->_name.'
                SET '.$this->_left.' = '.$this->_left.' - 1
                WHERE '.$this->_left.' >= '.$deleteLeft.'
                AND '.$this->_right.' < '.$deleteRight.';');
            $statement->execute();
         }

         if ($res) {
            $statement = $this->_db->prepare(
               'UPDATE '.$this->_name.'
                SET '.$this->_left.' = '.$this->_left.' - 2
                WHERE '.$this->_left.' >= '.$deleteLeft.'
                AND '.$this->_right.' > '.$deleteRight.';');
            $statement->execute();
         }

         if ($res) {
            $statement = $this->_db->prepare(
               'UPDATE '.$this->_name.'
                SET '.$this->_right.' = '.$this->_right.' - 1
                WHERE '.$this->_right.' >= '.$deleteLeft.'
                AND '.$this->_right.' < '.$deleteRight.';');
            $statement->execute();
         }

         if ($res) {
            $statement = $this->_db->prepare(
               'UPDATE '.$this->_name.'
                SET '.$this->_right.' = '.$this->_right.' - 2
                WHERE '.$this->_right.' >= '.$deleteLeft.'
                AND '.$this->_right.' > '.$deleteRight.';');
            $statement->execute();
         }

         if ($res) {
            $this->_db->commit();
         } else {
            $this->_db->rollback();
         }
      }
      return $res;
   }

   public function UpdateById($data) {
      // on ne peut mettre à jour les donnée hiérarchique
      // ie on ne peut déplacer un noeud dans l'arbre.
      unset($data[$this->_parent]); // ne fait pas partie de la table
      unset($data[$this->_left]);   //ne peut être changé
      unset($data[$this->_right]);  //ne peut être changé
      unset($data[$this->_level]);  //ne peut être changé
      $res =  parent::UpdateById($data);
      return $res;
   }

	public function insert(array $data) {
      # select left and level of parent
      $parentId = $data[$this->_parent];

      $this->_db->beginTransaction();
      $parent = $this->_db->select();
      if (null != $this->_level) {
         $fields = array('parent_left' => $this->_left,
                         'parent_level' => $this->_level);
      } else {
         $fields = array('parent_left' => $this->_left,);
      }

      $parent->from($this->_name, $fields)
             ->where($this->_primary[1].' = :_parentId');
      $statement = $this->_db->prepare($parent);
      $statement->execute(array('_parentId' => $parentId));
      list($parentLeft, $parentLevel) = array_values($statement->fetch());

      $res = false;
      if ($parentLeft) {
         #update tree
         $statement = $this->_db->prepare(
            'UPDATE '.$this->_name.'
             SET '.$this->_left.' = '.$this->_left.' + 2
             WHERE '.$this->_left.' > '.$parentLeft.';');
         $res = $statement->execute();
         if ($res) {
            $statement = $this->_db->prepare(
               'UPDATE '.$this->_name.'
                SET '.$this->_right.' = '.$this->_right.' + 2
                WHERE '.$this->_right.' > '.$parentLeft.';');
            $statement->execute();
         }

         #insert node
         if ($res) {
            unset($data[$this->_parent]);
            $data[$this->_left] = $parentLeft + 1;
            $data[$this->_right] = $parentLeft + 2;
            if (null != $this->_level)
               $data[$this->_level] = $parentLevel + 1;
            $res =  parent::insert($data);
   		}
         if ($res) {
            $this->_db->commit();
         } else {
            $this->_db->rollback();
         }
      }
      return $res;
	}

    /**
     * Support method for fetching rows.
     *
     * @param  string|array $where  OPTIONAL An SQL WHERE clause.
     * @param  string|array $order  OPTIONAL An SQL ORDER clause.
     * @param  int          $count  OPTIONAL An SQL LIMIT count.
     * @param  int          $offset OPTIONAL An SQL LIMIT offset.
     * @return array The row results, in FETCH_ASSOC mode.
     */
    protected function _fetch($where = null, $order = null, $count = null, $offset = null)
    {
        // selection tool
        $select = $this->_db->select();

        //no _parent col on master table
        $cols = $this->_cols;
        unset($cols[array_search($this->_parent,$cols)]);

        // the FROM clause
        $select->from($this->_name, $cols, $this->_schema);
        // add the parent col
        $select->join(array('parent' => $this->_name),
                      '(parent.'.$this->_left.' < workgroup.'.$this->_left.') AND
                       (parent.'.$this->_right.' > workgroup.'.$this->_right.') AND
                       (parent.'.$this->_level.' = workgroup.'.$this->_level.' -1)',
                      array('parent_id' => 'parent.'.$this->_primary[1].''));

        // the WHERE clause
        $where = (array) $where;
        foreach ($where as $key => $val) {
            // is $key an int?
            if (is_int($key)) {
                // $val is the full condition
                $select->where($val);
            } else {
                // $key is the condition with placeholder,
                // and $val is quoted into the condition
                $select->where($key, $val);
            }
        }

        // the ORDER clause
        if (!is_array($order)) {
            $order = array($order);
        }
        foreach ($order as $val) {
            $select->order($val);
        }

        // the LIMIT clause
        $select->limit($count, $offset);
        // return the results
        $stmt = $this->_db->query($select);
        $data = $stmt->fetchAll(Zend_Db::FETCH_ASSOC);
        return $data;
    }

    public function update(array $data, $where)
    {
      unset($this->_cols[array_search($this->_parent,$this->_cols)]);
      unset($data[$this->_parent]);
      $res = parent::update($data, $where);
      $this->_cols[] = $this->_parent;
      return $res;
    }

   protected function _parent($row, $fiels) {
      $parent = $this->_parents($row, $fiels)
             ->order($this->_right)
             ->limit(1);
      return $parent;
   }
   protected function _parents($row, $fiels) {
      $parent = $this->_db->select();
      $parent->from($this->_name, $fiels)
             ->where($this->_left.'  < '.$row->{$this->_left})
             ->where($this->_right.' > '.$row->{$this->_right});
      return $parent;
   }
   protected function _childs($row, $fiels) {
      $childs = $this->_db->select();
      $childs->from($this->_name, $fiels)
             ->where($this->_left.'  > '.$row->{$this->_left})
             ->where($this->_right.' < '.$row->{$this->_right});
      return $childs;
   }

    /**
     * This is the find Zend_Db_Table Abstract method
     * But the where closes are prefixed by the table name
     *
     * Fetches rows by primary key.
     * The arguments specify the primary key values.
     * If the table has a multi-column primary key, you must
     * pass as many arguments as the count of column in the
     * primary key.
     *
     * To find multiple rows by primary key, the argument
     * should be an array.  If the table has a multi-column
     * primary key, all arguments must be arrays with the
     * same number of elements.
     *
     * The find() method always returns a Rowset object,
     * even if only one row was found.
     *
     * @param  mixed                         The value(s) of the primary key.
     * @return Zend_Db_Table_Rowset_Abstract Row(s) matching the criteria.
     * @throws Zend_Db_Table_Exception
     */
    public function find()
    {
        $args = func_get_args();
        $keyNames = array_values((array) $this->_primary);

        if (empty($args)) {
            require_once 'Zend/Db/Table/Exception.php';
            throw new Zend_Db_Table_Exception("No value(s) specified for the primary key");
        }

        if (count($args) != count($keyNames)) {
            require_once 'Zend/Db/Table/Exception.php';
            throw new Zend_Db_Table_Exception("Missing value(s) for the primary key");
        }

        $whereList = array();
        $numberTerms = 0;
        foreach ($args as $keyPosition => $keyValues) {
            // Coerce the values to an array.
            // Don't simply typecast to array, because the values
            // might be Zend_Db_Expr objects.
            if (!is_array($keyValues)) {
                $keyValues = array($keyValues);
            }
            if ($numberTerms == 0) {
                $numberTerms = count($keyValues);
            } else if (count($keyValues) != $numberTerms) {
                require_once 'Zend/Db/Table/Exception.php';
                throw new Zend_Db_Table_Exception("Missing value(s) for the primary key");
            }
            for ($i = 0; $i < count($keyValues); ++$i) {
                $whereList[$i][$keyPosition] = $keyValues[$i];
            }
        }
        $whereClause = null;
        if (count($whereList)) {
            $whereOrTerms = array();
            foreach ($whereList as $keyValueSets) {
                $whereAndTerms = array();
                foreach ($keyValueSets as $keyPosition => $keyValue) {
                    $whereAndTerms[] = $this->_db->quoteInto(
                        $this->_db->quoteIdentifier($this->_name).'.'
                       .$this->_db->quoteIdentifier($keyNames[$keyPosition], true) . ' = ?',
                        $keyValue
                    );
                }
                $whereOrTerms[] = '(' . implode(' AND ', $whereAndTerms) . ')';
            }
            $whereClause = '(' . implode(' OR ', $whereOrTerms) . ')';
        }

        return $this->fetchAll($whereClause);
    }

}

Vous aurrez noté la présence de la méthode find alors qu’elle est disponible dans la classe Zend_Db_Table. Cela vient du fait que je fais une auto-jointure je joins la table sur elle-même. Du coup tous les champs de la table sont en double dans la requête. Or la méthode find construit des closes where simple. Il est nécessaire dans ce cas de les préfixer du nom de la table c’est ce que j’ai ajouté à la méthode find.

Conclusion

Cette façon de dériver la classe Zend_Db_Table permet d’imaginer toute sorte de mapping entre un objet et un ensemble de tables dans la base. Par exemple un modèle ou les adresses sont dans une table à part des clients alors que l’objet de mapping remonte toujours l’ensemble. Ou la remonté systématique des valeurs des tables de références etc.

A+JYT
ZIP File : Hierarchical Table

Utiliser une base de données avec Zend Framwork

Lundi 26 novembre 2007

Zend Framework contient déjà tout le nécessaire pour accéder à de nombreuse base de données. Je vais aborder ici son utilisation. La littérature sur la connexion à une base est suffisamment dense pour que je considère que celle-ci est déjà faite. Je vais donc me concentrer sur l’accès aux données et leurs manipulations.

Approche conceptuelle

Une base de données est avant tout un gisement organisé de données structurée. Que ce soit une base de donnée relationnelle ou un annuaire, ou encore un fichier de donnée structurée, on se trouve devant deux notion fondamentales. Une collection et des éléments de cette dernière. Dans une base de données les collections sont appelées Tables et les éléments Rows. Une base de donnée relationnelle peut comporter bien d’autre objets mais dans sont fondement elle est constituée de Tables qui contiennent des Rows. Cette séparation distincte de ces deux éléments et primordiale pour obtenir une conception claire de son application. En effet n ne fait pas les mêmes opérations sur une table que sur un row. En même temps on voit que ces deux types d’objet sont intimement liés.

La représentation Objet

Zend Framwork nous fournit deux classe pour représenter ses éléments. Il s’agit de Zend_Db_Table_Abstract et Zend_Db_Table_Row_Abstract. Comme leur nom l’indique ces classes étant abstraites, ne sont pas utilisables directement. Pour aller au plus simple ZF fournit aussi un classe Zend_Db_Table dérivant de Zend_Db_Table_Abstract qui par simple instanciation permet de manipuler une table. Cette classe fournit déjà pas mal de possibilités mais on peut constater qu’il n’existe pas de classe pour les row correspondante. Simplement parce que de façon très générales un classe Zend_Db_Table_Row ne comporterait rien de particulièrement intéressent. En effet les opérations que l’on peut faire sur un row dépendant complètement du type de données qu’il contient. Et a priori il peut s’agit de n’importe quoi.

Approche par dérivation

Si on définit des classe abstraite c’est pour qu’elles soient dérivées en classes concrètes. Et si les concepteurs de ZF s’en sont donné la peine ce n’est surement pas pour la beauté de la chose. Que peut nous apporter la dérivation des classes Zend_Db_Table_Abstract et Zend_Db_Table_Row_Abstract ? La première chose que nous apporte la dérivation c’est d’associer une classe drivée de Zend_Db_Table_Abstract à une table particulière de notre base. Par exemple Client_Table lié à la table client de la base. Pour cela il suffit de très peut de chose

Class Client_Table extends Zend_Db_Table_Abstract {
   protected $_name = 'client';
}

Après instanciation de cette classe un appel aux méthodes de consultation de la table remontera des rows de la table client.
Ces dernier étant les objets qu’est sensé manipuler mon modèle. La conception de mon application m’a normalement amené à la manipulation de clients et donc un ensemble de méthode pour parvenir à me fins. Mais ainsi définit ma Client_Table me retourne juste des données. Il serait intéressant qu’elle me donne des objets clients c’est justement ce que permet la dérivation de Zend_Db_Table_Row_Abstract.je peux ainsi définir ma classe

Class Client_Row extends Zend_Db_Table_Row_Abstract {
}

J’ai donc une classe pour mes clients à la quelle je vais pouvoir attacher les méthodes que ma conception ma permit de définir. Reste à associer la classe Client_Table à la classe Client_Row cela se fait très simplement.

Class Client_Table extends Zend_Db_Table_Abstract {
   protected $_name = 'client';
   protected $_rowClass = 'Client_Row';
 }

Je bénéficie maintenant de tous les méthodes de manipulation de ma table définit par ZF et à chaque fois que je récupère un row j’ai entre mes mains un client qui possède toutes les méthodes de manipulation de celui-ci.

le lien inverse

On vient de voir que la classe Client_Table et en relation avec la classe Client_Row en ce sens que tout row sortit de la table est un Client_Row. Mais qu’en est-il dans l’autre sens ? La réponse est La relation existe aussi. Intrinsèquement, la classe Client_Row connait la table qui à donné naissance à ses instances. En clair chaque Client_Row sortit d’une instance de Client_Table contient un référence à cette instance.
Il devient alors simple d’accéder à la table depuis le row. On peut se demander pourquoi. Simplement pour se faciliter la vie.

 //trouver l’enregistrement client d’id 25
$aclient = $clientTable->find(25);
$aClient->maMethode();
$aClient->save();

Ce n’est qu’une facilité on pourrait très bien en repasser par une instance de la table pour faire cette mise à jour. Mais le client ayant sa propre instance de la table pourquoi ne pas en profiter. ZF a d’ailleurs prévu cela et fournit la méthode save et quelques autres.
On le voit il n’est pas bien compliqué d’utiliser la table à partir du row.

Et l’ajout de donnée ?

Si je crée une instance de Client_Row directement je n’aurais pas associé la table à mon nouvel objet. J’aurais bien toutes les méthodes définies mais je ne pourrais mettre mon objet en base sans en passer par une instance de la table. Pour recréer se lient on peut, soit, passer part la méthode setTable, soit par le tableau de paramètre du constructeur. Personnellement j’ai l’habitude de demander mes rows à la Table pourquoi ne pas demander les nouveaux rows à la table.

Class Client_Table extends Zend_Db_Table_Abstract {
   protected $_name = 'client';
   protected $_rowClass = 'Client_Row';

   /**
    * Make new row associated to this table.
    *
    * @param StdClass|array $obj OPTIONAL object to cast
    * @return Fast_Db_Row
    */
    public function newRow($obj = null) {
      if ($obj)
      {
         if (is_object($obj))
            $obj = get_object_vars($obj);
         $row = $this->createRow($obj);
      } else {
         $row = $this->createRow();
      }
      return $row;
   }
 }

Ainsi j’ai le même fonctionnement que précédemment.

//Créer un nouvel enregistrement client.
$aclient = $clientTable-> newRow();
$aClient->maMethode();
$aClient->save();

Est-ce tout ?

Nous venons de voir que cette approche permet de faire un mapping d’une classe issue de ma conception sur les enregistrements d’une table de ma base. ZF offre au passage toute la manipulation des relations. Et toutes les fonctionnalités de manipulation des données sur de la table ajout, mise à jour, suppression. Mais cette approche permet aussi d’enrichir cette couverture déjà riche de méthode spécifique à notre modèle de données. Ainsi la classe Client_Table peut être enrichie de toutes les méthodes qui me sont nécessaire et qui doivent me retourner un Client_Row. J’ai un réceptacle naturel pour toutes les requêtes qui retourne un ou des client_Row mais aussi des données ayant rapport direct avec la table client.
Par exemple la méthode getBoutiqueList qui me retourne la liste des noms de boutique trouve sa place dans Boutique_Table.
Je bénéficie au passage d’un nettoyage automatique des champs de mes objets. Il arrive parfois qu’on associe des valeurs à l’ensemble des données d’un formulaire pour se simplifier la vie. Si je donne tous ses champs de formulaire à la méthode newRow mon objet ne contiendra que les valeurs correspondant à un champ de la table les autres étant supprimé au passage. Notez que si vous utilisez des tableaux de donnée à la place d’objet la méthode createRow est là pour vous. De même pour obtenir le tableau des données d’un objet métier vous avez la méthode toArray. Pour ma part j’ai ajouté la méthode toStdClass pour obtenir un objet standard qui est parfois intéressant (pour la mise en session par exemple).

Restrinction d’accès

Les ACL permettent de restreindre l’accès aux fonctionnalités de votre application. Pour restreindre l’étendu des données il est nécessaire d’en passer par des restrictions sur les requêtes. Il est parfaitement possible d’intégrer cette restriction dans la table elle-même

Class Client_Table extends Zend_Db_Table_Abstract {
   protected $_name = 'client';
   protected $_rowClass = 'Client_Row';
   /**
   * Restriction for query
   * @var string
   */
   protected $_restrict = array('cli_level > 0');
   public function __construct($config = array())
   {
      parent::__construct($config);
      $user = Zend_Auth::getInstance()->getIdentity();
      if ($user) {
         $this->_restrict[] = 'cli_group IN
          (SELECT grp_id FROM group WHERE usr_id = '.$user->usr_id.')';
      } else {
         //sans identité on ne peut rien voir dans la base
         $this->_restrict = 'false';
      }
   }

    /**
     * Fetches one row in an object of type Zend_Db_Table_Row_Abstract,
     * or returns Boolean false if no row matches the specified criteria.
     *
     * @param string|array $where  OPTIONAL An SQL WHERE clause.
     * @param string|array $order   OPTIONAL An SQL ORDER clause.
     * @param boolean $restrict     OPTIONAL use restrict SQL clause.
     * @return Fast_Db_Row  The row results per the
     *     Zend_Db_Adapter fetch mode, or null if no row found.
     */
   public function fetchRow($where = null,
                            $order = null,
                            $restrict = true)
   {
      if ($restrict&&
          isset($this->_restrict)&&
          is_string($this->_restrict))
      {
         if (is_array($where))
         {
            $where[] = $this->_restrict;
         } else {
            $where = '('.$this->_restrict.') AND ('.$where.')';
         }
      } elseif ($restrict&&
                isset($this->_restrict)&&
                is_array($this->_restrict))
      {
         if (is_array($where))
         {
            $where = array_merge($where, $this->_restrict);
         } else {
            foreach ($this->_restrict as $contraint) {
               $where = '('.$contraint.') AND ('.$where.')';
            }
         }
      }
      $res = parent::fetchRow($where,$order);
      return $res;
   }

   /**
    * Make new row associated to this table.
    *
    * @param StdClass|array $obj OPTIONAL object to cast
    * @return Fast_Db_Row
    */
    public function newRow($obj = null) {
      if ($obj)
      {
         if (is_object($obj))
            $obj = get_object_vars($obj);
         $row = $this->createRow($obj);
      } else {
         $row = $this->createRow();
      }
      return $row;
   }
 }

Ainsi lorsqu’on fait appel à une méthode d’extraction de données sur la table la restriction s’applique automatiquement. Notez que j’ai prévu de pouvoir débrailler cette restriction avec le paramètre supplémentaire $restrict cela peut parfois s’avérer utile.

Conclusion

Cette approche conceptuelle permet facilement de coller la conception en classe sur son modèle de donnée. Elle permet un découpage logique de la partie métier de son application. Mais elle ne résous pas tout. Il reste en effet des requêtes qu’on ne sait pas toujours où placer. Mais les possibilités d’extensions de ses classes est grand et laisse libre court à votre imagination.
A+JYT