Coverage for Doctrine_Connection_UnitOfWork

Back to coverage report

1 <?php
2 /*
3  *  $Id: UnitOfWork.php 3195 2007-11-20 14:26:42Z romanb $
4  *
5  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
6  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
7  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
8  * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
9  * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
10  * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
11  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
12  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
13  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
14  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
15  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
16  *
17  * This software consists of voluntary contributions made by many individuals
18  * and is licensed under the LGPL. For more information, see
19  * <http://www.phpdoctrine.com>.
20  */
21 Doctrine::autoload('Doctrine_Connection_Module');
22 /**
23  * Doctrine_Connection_UnitOfWork
24  *
25  * @package     Doctrine
26  * @subpackage  Connection
27  * @license     http://www.opensource.org/licenses/lgpl-license.php LGPL
28  * @link        www.phpdoctrine.com
29  * @since       1.0
30  * @version     $Revision: 3195 $
31  * @author      Konsta Vesterinen <kvesteri@cc.hut.fi>
32  */
33 class Doctrine_Connection_UnitOfWork extends Doctrine_Connection_Module
34 {
35     /**
36      * buildFlushTree
37      * builds a flush tree that is used in transactions
38      *
39      * The returned array has all the initialized components in
40      * 'correct' order. Basically this means that the records of those
41      * components can be saved safely in the order specified by the returned array.
42      *
43      * @param array $tables     an array of Doctrine_Table objects or component names
44      * @return array            an array of component names in flushing order
45      */
46     public function buildFlushTree(array $tables)
47     {
48         $tree = array();
49         foreach ($tables as $k => $table) {
50
51             if ( ! ($table instanceof Doctrine_Table)) {
52                 $table = $this->conn->getTable($table, false);
53             }
54             $nm     = $table->getComponentName();
55
56             $index  = array_search($nm, $tree);
57
58             if ($index === false) {
59                 $tree[] = $nm;
60                 $index  = max(array_keys($tree));
61             }
62
63             $rels = $table->getRelations();
64
65             // group relations
66
67             foreach ($rels as $key => $rel) {
68                 if ($rel instanceof Doctrine_Relation_ForeignKey) {
69                     unset($rels[$key]);
70                     array_unshift($rels, $rel);
71                 }
72             }
73
74             foreach ($rels as $rel) {
75                 $name   = $rel->getTable()->getComponentName();
76                 $index2 = array_search($name,$tree);
77                 $type   = $rel->getType();
78
79                 // skip self-referenced relations
80                 if ($name === $nm) {
81                     continue;
82                 }
83
84                 if ($rel instanceof Doctrine_Relation_ForeignKey) {
85                     if ($index2 !== false) {
86                         if ($index2 >= $index)
87                             continue;
88
89                         unset($tree[$index]);
90                         array_splice($tree,$index2,0,$nm);
91                         $index = $index2;
92                     } else {
93                         $tree[] = $name;
94                     }
95
96                 } elseif ($rel instanceof Doctrine_Relation_LocalKey) {
97                     if ($index2 !== false) {
98                         if ($index2 <= $index)
99                             continue;
100
101                         unset($tree[$index2]);
102                         array_splice($tree,$index,0,$name);
103                     } else {
104                         array_unshift($tree,$name);
105                         $index++;
106                     }
107                 } elseif ($rel instanceof Doctrine_Relation_Association) {
108                     $t = $rel->getAssociationFactory();
109                     $n = $t->getComponentName();
110
111                     if ($index2 !== false)
112                         unset($tree[$index2]);
113
114                     array_splice($tree, $index, 0, $name);
115                     $index++;
116
117                     $index3 = array_search($n, $tree);
118
119                     if ($index3 !== false) {
120                         if ($index3 >= $index)
121                             continue;
122
123                         unset($tree[$index]);
124                         array_splice($tree, $index3, 0, $n);
125                         $index = $index2;
126                     } else {
127                         $tree[] = $n;
128                     }
129                 }
130             }
131         }
132         return array_values($tree);
133     }
134
135     /**
136      * saves the given record
137      *
138      * @param Doctrine_Record $record
139      * @return void
140      */
141     public function saveGraph(Doctrine_Record $record)
142     {
143         $conn = $this->getConnection();
144
145         $state = $record->state();
146         if ($state === Doctrine_Record::STATE_LOCKED) {
147             return false;
148         }
149
150         $record->state(Doctrine_Record::STATE_LOCKED);
151         
152         $conn->beginTransaction();
153         $saveLater = $this->saveRelated($record);
154
155         $record->state($state);
156
157         if ($record->isValid()) {
158             $event = new Doctrine_Event($record, Doctrine_Event::RECORD_SAVE);
159
160             $record->preSave($event);
161
162             $record->getTable()->getRecordListener()->preSave($event);
163             $state = $record->state();
164
165             if ( ! $event->skipOperation) {
166                 switch ($state) {
167                     case Doctrine_Record::STATE_TDIRTY:
168                         $this->insert($record);
169                         break;
170                     case Doctrine_Record::STATE_DIRTY:
171                     case Doctrine_Record::STATE_PROXY:
172                         $this->update($record);
173                         break;
174                     case Doctrine_Record::STATE_CLEAN:
175                     case Doctrine_Record::STATE_TCLEAN:
176
177                         break;
178                 }
179             }
180
181             $record->getTable()->getRecordListener()->postSave($event);
182              
183             $record->postSave($event);
184         } else {
185             $conn->transaction->addInvalid($record);
186         }
187
188         $state = $record->state();
189
190         $record->state(Doctrine_Record::STATE_LOCKED);
191
192         foreach ($saveLater as $fk) {
193             $alias = $fk->getAlias();
194
195             if ($record->hasReference($alias)) {
196                 $obj = $record->$alias;
197             
198                 // check that the related object is not an instance of Doctrine_Null
199                 if ( ! ($obj instanceof Doctrine_Null)) {
200                     $obj->save($conn);
201                 }
202             }
203         }
204
205         // save the MANY-TO-MANY associations
206         $this->saveAssociations($record);
207
208         $record->state($state);
209         
210         $conn->commit();
211
212         return true;
213     }
214
215     /**
216      * saves the given record
217      *
218      * @param Doctrine_Record $record
219      * @return void
220      */
221     public function save(Doctrine_Record $record)
222     {
223         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_SAVE);
224
225         $record->preSave($event);
226
227         $record->getTable()->getRecordListener()->preSave($event);
228
229         if ( ! $event->skipOperation) {
230             switch ($record->state()) {
231                 case Doctrine_Record::STATE_TDIRTY:
232                     $this->insert($record);
233                     break;
234                 case Doctrine_Record::STATE_DIRTY:
235                 case Doctrine_Record::STATE_PROXY:
236                     $this->update($record);
237                     break;
238                 case Doctrine_Record::STATE_CLEAN:
239                 case Doctrine_Record::STATE_TCLEAN:
240                     // do nothing
241                     break;
242             }
243         }
244
245         $record->getTable()->getRecordListener()->postSave($event);
246         
247         $record->postSave($event);
248     }
249
250     /**
251      * deletes given record and all the related composites
252      * this operation is isolated by a transaction
253      *
254      * this event can be listened by the onPreDelete and onDelete listeners
255      *
256      * @return boolean      true on success, false on failure
257      */
258     public function delete(Doctrine_Record $record)
259     {
260         if ( ! $record->exists()) {
261             return false;
262         }
263         $this->conn->beginTransaction();
264
265         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_DELETE);
266
267         $record->preDelete($event);
268         
269         $table = $record->getTable();
270
271         $table->getRecordListener()->preDelete($event);
272
273         $state = $record->state();
274
275         $record->state(Doctrine_Record::STATE_LOCKED);
276
277         $this->deleteComposites($record);
278
279         if ( ! $event->skipOperation) {
280             $record->state(Doctrine_Record::STATE_TDIRTY);
281             if ($table->getOption('joinedParents')) {
282
283                 foreach ($table->getOption('joinedParents') as $parent) {
284                     $parentTable = $table->getConnection()->getTable($parent);
285                     
286                     $this->conn->delete($parentTable, $record->identifier());
287                 }
288             }
289             $this->conn->delete($table, $record->identifier());
290
291             $record->state(Doctrine_Record::STATE_TCLEAN);
292         } else {
293             // return to original state   
294             $record->state($state);
295         }
296
297         $table->getRecordListener()->postDelete($event);
298
299         $record->postDelete($event);
300         
301         $table->removeRecord($record);
302
303         $this->conn->commit();
304
305         return true;
306     }
307     
308     /**
309      * @todo Description. See also the todo for deleteMultiple().
310      */
311     public function deleteRecord(Doctrine_Record $record)
312     {
313         $ids = $record->identifier();
314         $tmp = array();
315         
316         foreach (array_keys($ids) as $id) {
317             $tmp[] = $id . ' = ? ';
318         }
319         
320         $params = array_values($ids);
321
322         $query = 'DELETE FROM '
323                . $this->conn->quoteIdentifier($record->getTable()->getTableName())
324                . ' WHERE ' . implode(' AND ', $tmp);
325
326
327         return $this->conn->exec($query, $params);
328     }
329
330     /**
331      * deleteMultiple
332      * deletes all records from the pending delete list
333      *
334      * @return void
335      * @todo Refactor. Maybe move to the Connection class? Sometimes UnitOfWork constructs
336      *       queries itself and sometimes it leaves the sql construction to Connection.
337      *       This should be changed.
338      */
339     public function deleteMultiple(array $records)
340     {        
341         foreach ($this->delete as $name => $deletes) {
342             $record = false;
343             $ids = array();
344             
345             // Note: Why is the last element's table identifier checked here and then 
346             // the table object from $deletes[0] used???
347             if (is_array($deletes[count($deletes)-1]->getTable()->getIdentifier()) &&
348                     count($deletes) > 0) {
349                 $table = $deletes[0]->getTable();
350                 $query = 'DELETE FROM '
351                        . $this->conn->quoteIdentifier($table->getTableName())
352                        . ' WHERE ';
353
354                 $params = array();
355                 $cond = array();
356                 foreach ($deletes as $k => $record) {
357                     $ids = $record->identifier();
358                     $tmp = array();
359                     foreach (array_keys($ids) as $id) {
360                         $tmp[] = $table->getColumnName($id) . ' = ? ';
361                     }
362                     $params = array_merge($params, array_values($ids));
363                     $cond[] = '(' . implode(' AND ', $tmp) . ')';
364                 }
365                 $query .= implode(' OR ', $cond);
366
367                 $this->conn->execute($query, $params);
368             } else {
369                 foreach ($deletes as $k => $record) {
370                     $ids[] = $record->getIncremented();
371                 }
372                 // looks pretty messy. $record should be already out of scope. ugly php behaviour.
373                 // even the php manual agrees on that and recommends to unset() the last element
374                 // immediately after the loop ends.
375                 $table = $record->getTable();
376                 if ($record instanceof Doctrine_Record) {
377                     $params = substr(str_repeat('?, ', count($ids)), 0, -2);
378     
379                     $query = 'DELETE FROM '
380                            . $this->conn->quoteIdentifier($record->getTable()->getTableName())
381                            . ' WHERE '
382                            . $table->getColumnName($table->getIdentifier())
383                            . ' IN(' . $params . ')';
384         
385                     $this->conn->execute($query, $ids);
386                 }
387             }
388         }
389     }
390
391     /**
392      * saveRelated
393      * saves all related records to $record
394      *
395      * @throws PDOException         if something went wrong at database level
396      * @param Doctrine_Record $record
397      */
398     public function saveRelated(Doctrine_Record $record)
399     {
400         $saveLater = array();
401         foreach ($record->getReferences() as $k => $v) {
402             $rel = $record->getTable()->getRelation($k);
403
404             $local = $rel->getLocal();
405             $foreign = $rel->getForeign();
406
407             if ($rel instanceof Doctrine_Relation_ForeignKey) {
408                 $saveLater[$k] = $rel;
409             } else if ($rel instanceof Doctrine_Relation_LocalKey) {
410                 // ONE-TO-ONE relationship
411                 $obj = $record->get($rel->getAlias());
412
413                 // Protection against infinite function recursion before attempting to save
414                 if ($obj instanceof Doctrine_Record && $obj->isModified()) {
415                     $obj->save($this->conn);
416                     
417                     /** Can this be removed?
418                     $id = array_values($obj->identifier());
419
420                     foreach ((array) $rel->getLocal() as $k => $field) {
421                         $record->set($field, $id[$k]);
422                     }
423                     */
424                 }
425             }
426         }
427
428         return $saveLater;
429     }
430
431     /**
432      * saveAssociations
433      *
434      * this method takes a diff of one-to-many / many-to-many original and
435      * current collections and applies the changes
436      *
437      * for example if original many-to-many related collection has records with
438      * primary keys 1,2 and 3 and the new collection has records with primary keys
439      * 3, 4 and 5, this method would first destroy the associations to 1 and 2 and then
440      * save new associations to 4 and 5
441      *
442      * @throws Doctrine_Connection_Exception         if something went wrong at database level
443      * @param Doctrine_Record $record
444      * @return void
445      */
446     public function saveAssociations(Doctrine_Record $record)
447     {
448         foreach ($record->getReferences() as $k => $v) {
449             $rel = $record->getTable()->getRelation($k);
450             
451             if ($rel instanceof Doctrine_Relation_Association) {   
452                 $v->save($this->conn);
453
454                 $assocTable = $rel->getAssociationTable();
455                 foreach ($v->getDeleteDiff() as $r) {
456                     $query = 'DELETE FROM ' . $assocTable->getTableName()
457                            . ' WHERE ' . $rel->getForeign() . ' = ?'
458                            . ' AND ' . $rel->getLocal() . ' = ?';
459
460                     $this->conn->execute($query, array($r->getIncremented(), $record->getIncremented()));
461                 }
462
463                 foreach ($v->getInsertDiff() as $r) {
464                     $assocRecord = $assocTable->create();
465                     $assocRecord->set($assocTable->getFieldName($rel->getForeign()), $r);
466                     $assocRecord->set($assocTable->getFieldName($rel->getLocal()), $record);
467
468                     $this->saveGraph($assocRecord);
469                 }
470             }
471         }
472     }
473
474     /**
475      * deletes all related composites
476      * this method is always called internally when a record is deleted
477      *
478      * @throws PDOException         if something went wrong at database level
479      * @return void
480      */
481     public function deleteComposites(Doctrine_Record $record)
482     {
483         foreach ($record->getTable()->getRelations() as $fk) {
484             if ($fk->isComposite()) {
485                 $obj = $record->get($fk->getAlias());
486                 if ($obj instanceof Doctrine_Record && 
487                         $obj->state() != Doctrine_Record::STATE_LOCKED)  {
488                     $obj->delete($this->conn);
489                 }
490             }
491         }
492     }
493
494     /**
495      * saveAll
496      * persists all the pending records from all tables
497      *
498      * @throws PDOException         if something went wrong at database level
499      * @return void
500      */
501     public function saveAll()
502     {
503         // get the flush tree
504         $tree = $this->buildFlushTree($this->conn->getTables());
505
506         // save all records
507         foreach ($tree as $name) {
508             $table = $this->conn->getTable($name);
509
510             foreach ($table->getRepository() as $record) {
511                 $this->save($record);
512             }
513         }
514
515         // save all associations
516         foreach ($tree as $name) {
517             $table = $this->conn->getTable($name);
518
519             foreach ($table->getRepository() as $record) {
520                 $this->saveAssociations($record);
521             }
522         }
523     }
524
525     /**
526      * updates given record
527      *
528      * @param Doctrine_Record $record   record to be updated
529      * @return boolean                  whether or not the update was successful
530      */
531     public function update(Doctrine_Record $record)
532     {
533         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_UPDATE);
534
535         $record->preUpdate($event);
536
537         $table = $record->getTable();
538
539         $table->getRecordListener()->preUpdate($event);
540
541         if ( ! $event->skipOperation) {
542             $identifier = $record->identifier();
543
544             if ($table->getOption('joinedParents')) {
545                 $dataSet = $this->formatDataSet($record);
546                 
547                 $component = $table->getComponentName();
548
549                 $classes = $table->getOption('joinedParents');
550                 $classes[] = $component;
551
552                 foreach ($record as $field => $value) {
553                     if ($value instanceof Doctrine_Record) {
554                         if ( ! $value->exists()) {
555                             $value->save();
556                         }
557                         $record->set($field, $value->getIncremented());
558                     }
559                 }
560
561                 foreach ($classes as $class) {
562                     $parentTable = $this->conn->getTable($class);
563
564                     $this->conn->update($this->conn->getTable($class), $dataSet[$class], $identifier);
565                 }
566             } else {
567                 $array = $record->getPrepared();
568                 
569                 $this->conn->update($table, $array, $identifier);
570             }
571             $record->assignIdentifier(true);
572         }
573         
574         $table->getRecordListener()->postUpdate($event);
575
576         $record->postUpdate($event);
577
578         return true;
579     }
580     
581     /**
582      * inserts a record into database
583      *
584      * @param Doctrine_Record $record   record to be inserted
585      * @return boolean
586      */
587     public function insert(Doctrine_Record $record)
588     {
589          // listen the onPreInsert event
590         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_INSERT);
591
592         $record->preInsert($event);
593         
594         $table = $record->getTable();
595
596         $table->getRecordListener()->preInsert($event);
597
598         if ( ! $event->skipOperation) {
599             if ($table->getOption('joinedParents')) {
600                 $dataSet = $this->formatDataSet($record);
601                 
602                 $component = $table->getComponentName();
603
604                 $classes = $table->getOption('joinedParents');
605                 $classes[] = $component;
606
607                 foreach ($classes as $k => $parent) {
608                     if ($k === 0) {
609                         $rootRecord = new $parent();
610
611                         $rootRecord->merge($dataSet[$parent]);
612
613                         $this->processSingleInsert($rootRecord);
614                     } else {
615                         foreach ((array) $rootRecord->identifier() as $id => $value) {
616                             $dataSet[$parent][$id] = $value;
617                         }
618
619                         $this->conn->insert($this->conn->getTable($parent), $dataSet[$parent]);
620                     }
621                 }
622             } else {
623                 $this->processSingleInsert($record);
624             }
625         }
626
627         $table->addRecord($record);
628
629         $table->getRecordListener()->postInsert($event);
630
631         $record->postInsert($event);
632
633         return true;
634     }
635     
636     /**
637      * @todo DESCRIBE WHAT THIS METHOD DOES, PLEASE!
638      */
639     public function formatDataSet(Doctrine_Record $record)
640     {
641      $table = $record->getTable();
642
643         $dataSet = array();
644     
645         $component = $table->getComponentName();
646     
647         $array = $record->getPrepared();
648     
649         foreach ($table->getColumns() as $columnName => $definition) {
650             $fieldName = $table->getFieldName($columnName);
651             if (isset($definition['primary']) && $definition['primary']) {
652                 continue;
653             }
654     
655             if (isset($definition['owner'])) {
656                 $dataSet[$definition['owner']][$fieldName] = $array[$fieldName];
657             } else {
658                 $dataSet[$component][$fieldName] = $array[$fieldName];
659             }
660         }    
661         
662         return $dataSet;
663     }
664     
665     /**
666      * @todo DESCRIBE WHAT THIS METHOD DOES, PLEASE!
667      */
668     public function processSingleInsert(Doctrine_Record $record)
669     {
670         $fields = $record->getPrepared();
671
672         if (empty($fields)) {
673             return false;
674         }
675         
676         $table = $record->getTable();
677         $identifier = (array) $table->getIdentifier();
678
679         $seq = $record->getTable()->sequenceName;
680
681         if ( ! empty($seq)) {
682             $id = $this->conn->sequence->nextId($seq);
683             $seqName = $table->getIdentifier();
684             $fields[$seqName] = $id;
685
686             $record->assignIdentifier($id);
687         }
688
689         $this->conn->insert($table, $fields);
690
691         if (empty($seq) && count($identifier) == 1 && $identifier[0] == $table->getIdentifier() &&
692             $table->getIdentifierType() != Doctrine::IDENTIFIER_NATURAL) {
693
694             if (strtolower($this->conn->getName()) == 'pgsql') {
695                 $seq = $table->getTableName() . '_' . $identifier[0];
696             }
697
698             $id = $this->conn->sequence->lastInsertId($seq);
699
700             if ( ! $id) {
701                 throw new Doctrine_Connection_Exception("Couldn't get last insert identifier.");
702             }
703
704             $record->assignIdentifier($id);
705         } else {
706             $record->assignIdentifier(true);
707         }    
708     }
709 }