Developpez.com - Zend Framework
X

Choisissez d'abord la catégorieensuite la rubrique :


Zend Framework - Ajouter un champ calculé dans une table

Date de publication : 10 octobre 2007

Par JYT (Les expériences Zend de Sekaijin) (Blog)
 

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.

I. Introduction
II. Ajouter un champ à un objet de mapping
III. La table facture
IV. Modifier la méthode _fetch
V. L'écriture
VI. Un exemple généralisé
VII. Conclusion


I. Introduction

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 facturation. 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 facturation.

Par exemple, lorsque je vérifie que le montant payé et bien le bon, il est 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 facturation. 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.


II. 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.


III. La table facture

La toute première étape consiste à créer un classe pour la table facture :
1.
2.
3.
4.
5.
6.
7.
8.
9.
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 sortirai ou entrerai 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, quelle que 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 :
1.
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 :
1.
2.
3.
4.
5.
6.
SELECT 
    facture .*, 
    SUM(lig_prix*lig_qte) AS fac_total 
FROM facture 
INNER JOIN lignes USING (fac_id) 
GROUP BY fac_id; 

IV. Modifier la méthode _fetch

Tout d'abord, voyons comment est faite la méthode de ZF :
    /**
     * 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, pas 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 clauses diverses.

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.


V. L'écriture

Tant que nous ne faisons que lire dans la table avec cet objet, nous n'aurons 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é. Toutefois, 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 a un comportement équivalent mais légèrement différent car update va tenter de mettre à jour même les champs qui ne sont pas présents 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.
1.
2.
3.
4.
5.
6.
7.
8.
    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 seront 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.


VI. 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'ai 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 niveaux hiérarchiques relatifs. Par exemple tous les nœuds aux rangs 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 à jour les bornes des éléments de la table.
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
138.
139.
140.
141.
142.
143.
144.
145.
146.
147.
148.
149.
150.
151.
152.
153.
154.
155.
156.
157.
158.
159.
160.
161.
162.
163.
164.
165.
166.
167.
168.
169.
170.
171.
172.
173.
174.
175.
176.
177.
178.
179.
180.
181.
182.
183.
184.
185.
186.
187.
188.
189.
190.
191.
192.
193.
194.
195.
196.
197.
198.
199.
200.
201.
202.
203.
204.
205.
206.
207.
208.
209.
210.
211.
212.
213.
214.
215.
216.
217.
218.
219.
220.
221.
222.
223.
224.
225.
226.
227.
228.
229.
230.
231.
232.
233.
234.
235.
236.
237.
238.
239.
240.
241.
242.
243.
244.
245.
246.
247.
248.
249.
250.
251.
252.
253.
254.
255.
256.
257.
258.
259.
260.
261.
262.
263.
264.
265.
266.
267.
268.
269.
270.
271.
272.
273.
274.
275.
276.
277.
278.
279.
280.
281.
282.
283.
284.
285.
286.
287.
288.
289.
290.
291.
292.
293.
294.
295.
296.
297.
298.
299.
300.
301.
302.
303.
304.
305.
306.
307.
308.
309.
310.
311.
312.
313.
314.
315.
316.
317.
318.
319.
320.
321.
322.
323.
324.
325.
326.
327.
328.
329.
330.
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 aurez 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 simples. 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.


VII. Conclusion

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

A+JYT





Valid XHTML 1.1!Valid CSS!

Copyright © 2007 Sekaijin. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Contacter le responsable de la rubrique Zend Framework