Coverage for Doctrine_Connection_UnitOfWork

Back to coverage report

1 <?php
2 /*
3  *  $Id: UnitOfWork.php 4096 2008-03-23 01:06:40Z jwage $
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.org>.
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.org
29  * @since       1.0
30  * @version     $Revision: 4096 $
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->beginInternalTransaction();
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             foreach ($record->getPendingDeletes() as $pendingDelete) {
182                 $pendingDelete->delete();
183             }
184
185             $record->getTable()->getRecordListener()->postSave($event);
186              
187             $record->postSave($event);
188         } else {
189             $conn->transaction->addInvalid($record);
190         }
191
192         $state = $record->state();
193
194         $record->state(Doctrine_Record::STATE_LOCKED);
195
196         foreach ($saveLater as $fk) {
197             $alias = $fk->getAlias();
198
199             if ($record->hasReference($alias)) {
200                 $obj = $record->$alias;
201             
202                 // check that the related object is not an instance of Doctrine_Null
203                 if ( ! ($obj instanceof Doctrine_Null)) {
204                     $obj->save($conn);
205                 }
206             }
207         }
208
209         // save the MANY-TO-MANY associations
210         $this->saveAssociations($record);
211
212         $record->state($state);
213         
214         $conn->commit();
215
216         return true;
217     }
218
219     /**
220      * saves the given record
221      *
222      * @param Doctrine_Record $record
223      * @return void
224      */
225     public function save(Doctrine_Record $record)
226     {
227         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_SAVE);
228
229         $record->preSave($event);
230
231         $record->getTable()->getRecordListener()->preSave($event);
232
233         if ( ! $event->skipOperation) {
234             switch ($record->state()) {
235                 case Doctrine_Record::STATE_TDIRTY:
236                     $this->insert($record);
237                     break;
238                 case Doctrine_Record::STATE_DIRTY:
239                 case Doctrine_Record::STATE_PROXY:
240                     $this->update($record);
241                     break;
242                 case Doctrine_Record::STATE_CLEAN:
243                 case Doctrine_Record::STATE_TCLEAN:
244                     // do nothing
245                     break;
246             }
247         }
248
249         $record->getTable()->getRecordListener()->postSave($event);
250         
251         $record->postSave($event);
252     }
253
254     /**
255      * deletes given record and all the related composites
256      * this operation is isolated by a transaction
257      *
258      * this event can be listened by the onPreDelete and onDelete listeners
259      *
260      * @return boolean      true on success, false on failure
261      */
262     public function delete(Doctrine_Record $record)
263     {
264         if ( ! $record->exists()) {
265             return false;
266         }
267         $this->conn->beginInternalTransaction();
268
269         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_DELETE);
270
271         $record->preDelete($event);
272         
273         $table = $record->getTable();
274
275         $table->getRecordListener()->preDelete($event);
276
277         $state = $record->state();
278
279         $record->state(Doctrine_Record::STATE_LOCKED);
280
281         $this->deleteComposites($record);
282
283         if ( ! $event->skipOperation) {
284             $record->state(Doctrine_Record::STATE_TDIRTY);
285             if ($table->getOption('joinedParents')) {
286
287                 foreach ($table->getOption('joinedParents') as $parent) {
288                     $parentTable = $table->getConnection()->getTable($parent);
289                     
290                     $this->conn->delete($parentTable, $record->identifier());
291                 }
292             }
293             $this->conn->delete($table, $record->identifier());
294
295             $record->state(Doctrine_Record::STATE_TCLEAN);
296         } else {
297             // return to original state   
298             $record->state($state);
299         }
300
301         $table->getRecordListener()->postDelete($event);
302
303         $record->postDelete($event);
304         
305         $table->removeRecord($record);
306
307         $this->conn->commit();
308
309         return true;
310     }
311     
312     /**
313      * @todo Description. See also the todo for deleteMultiple().
314      */
315     public function deleteRecord(Doctrine_Record $record)
316     {
317         $ids = $record->identifier();
318         $tmp = array();
319         
320         foreach (array_keys($ids) as $id) {
321             $tmp[] = $id . ' = ? ';
322         }
323         
324         $params = array_values($ids);
325
326         $query = 'DELETE FROM '
327                . $this->conn->quoteIdentifier($record->getTable()->getTableName())
328                . ' WHERE ' . implode(' AND ', $tmp);
329
330
331         return $this->conn->exec($query, $params);
332     }
333
334     /**
335      * deleteMultiple
336      * deletes all records from the pending delete list
337      *
338      * @return void
339      * @todo Refactor. Maybe move to the Connection class? Sometimes UnitOfWork constructs
340      *       queries itself and sometimes it leaves the sql construction to Connection.
341      *       This should be changed.
342      */
343     public function deleteMultiple(array $records)
344     {        
345         foreach ($this->delete as $name => $deletes) {
346             $record = false;
347             $ids = array();
348             
349             // Note: Why is the last element's table identifier checked here and then 
350             // the table object from $deletes[0] used???
351             if (is_array($deletes[count($deletes)-1]->getTable()->getIdentifier()) &&
352                     count($deletes) > 0) {
353                 $table = $deletes[0]->getTable();
354                 $query = 'DELETE FROM '
355                        . $this->conn->quoteIdentifier($table->getTableName())
356                        . ' WHERE ';
357
358                 $params = array();
359                 $cond = array();
360                 foreach ($deletes as $k => $record) {
361                     $ids = $record->identifier();
362                     $tmp = array();
363                     foreach (array_keys($ids) as $id) {
364                         $tmp[] = $table->getColumnName($id) . ' = ? ';
365                     }
366                     $params = array_merge($params, array_values($ids));
367                     $cond[] = '(' . implode(' AND ', $tmp) . ')';
368                 }
369                 $query .= implode(' OR ', $cond);
370
371                 $this->conn->execute($query, $params);
372             } else {
373                 foreach ($deletes as $k => $record) {
374                     $ids[] = $record->getIncremented();
375                 }
376                 // looks pretty messy. $record should be already out of scope. ugly php behaviour.
377                 // even the php manual agrees on that and recommends to unset() the last element
378                 // immediately after the loop ends.
379                 $table = $record->getTable();
380                 if ($record instanceof Doctrine_Record) {
381                     $params = substr(str_repeat('?, ', count($ids)), 0, -2);
382     
383                     $query = 'DELETE FROM '
384                            . $this->conn->quoteIdentifier($record->getTable()->getTableName())
385                            . ' WHERE '
386                            . $table->getColumnName($table->getIdentifier())
387                            . ' IN(' . $params . ')';
388         
389                     $this->conn->execute($query, $ids);
390                 }
391             }
392         }
393     }
394
395     /**
396      * saveRelated
397      * saves all related records to $record
398      *
399      * @throws PDOException         if something went wrong at database level
400      * @param Doctrine_Record $record
401      */
402     public function saveRelated(Doctrine_Record $record)
403     {
404         $saveLater = array();
405         foreach ($record->getReferences() as $k => $v) {
406             $rel = $record->getTable()->getRelation($k);
407
408             $local = $rel->getLocal();
409             $foreign = $rel->getForeign();
410
411             if ($rel instanceof Doctrine_Relation_ForeignKey) {
412                 $saveLater[$k] = $rel;
413             } else if ($rel instanceof Doctrine_Relation_LocalKey) {
414                 // ONE-TO-ONE relationship
415                 $obj = $record->get($rel->getAlias());
416
417                 // Protection against infinite function recursion before attempting to save
418                 if ($obj instanceof Doctrine_Record && $obj->isModified()) {
419                     $obj->save($this->conn);
420                     
421                     /** Can this be removed?
422                     $id = array_values($obj->identifier());
423
424                     foreach ((array) $rel->getLocal() as $k => $field) {
425                         $record->set($field, $id[$k]);
426                     }
427                     */
428                 }
429             }
430         }
431
432         return $saveLater;
433     }
434
435     /**
436      * saveAssociations
437      *
438      * this method takes a diff of one-to-many / many-to-many original and
439      * current collections and applies the changes
440      *
441      * for example if original many-to-many related collection has records with
442      * primary keys 1,2 and 3 and the new collection has records with primary keys
443      * 3, 4 and 5, this method would first destroy the associations to 1 and 2 and then
444      * save new associations to 4 and 5
445      *
446      * @throws Doctrine_Connection_Exception         if something went wrong at database level
447      * @param Doctrine_Record $record
448      * @return void
449      */
450     public function saveAssociations(Doctrine_Record $record)
451     {
452         foreach ($record->getReferences() as $k => $v) {
453             $rel = $record->getTable()->getRelation($k);
454             
455             if ($rel instanceof Doctrine_Relation_Association) {   
456                 $v->save($this->conn);
457
458                 $assocTable = $rel->getAssociationTable();
459                 foreach ($v->getDeleteDiff() as $r) {
460                     $query = 'DELETE FROM ' . $assocTable->getTableName()
461                            . ' WHERE ' . $rel->getForeign() . ' = ?'
462                            . ' AND ' . $rel->getLocal() . ' = ?';
463
464                     $this->conn->execute($query, array($r->getIncremented(), $record->getIncremented()));
465                 }
466
467                 foreach ($v->getInsertDiff() as $r) {
468                     $assocRecord = $assocTable->create();
469                     $assocRecord->set($assocTable->getFieldName($rel->getForeign()), $r);
470                     $assocRecord->set($assocTable->getFieldName($rel->getLocal()), $record);
471
472                     $this->saveGraph($assocRecord);
473                 }
474             }
475         }
476     }
477
478     /**
479      * deletes all related composites
480      * this method is always called internally when a record is deleted
481      *
482      * @throws PDOException         if something went wrong at database level
483      * @return void
484      */
485     public function deleteComposites(Doctrine_Record $record)
486     {
487         foreach ($record->getTable()->getRelations() as $fk) {
488             if ($fk->isComposite()) {
489                 $obj = $record->get($fk->getAlias());
490                 if ($obj instanceof Doctrine_Record && 
491                         $obj->state() != Doctrine_Record::STATE_LOCKED)  {
492                     $obj->delete($this->conn);
493                 }
494             }
495         }
496     }
497
498     /**
499      * saveAll
500      * persists all the pending records from all tables
501      *
502      * @throws PDOException         if something went wrong at database level
503      * @return void
504      */
505     public function saveAll()
506     {
507         // get the flush tree
508         $tree = $this->buildFlushTree($this->conn->getTables());
509
510         // save all records
511         foreach ($tree as $name) {
512             $table = $this->conn->getTable($name);
513
514             foreach ($table->getRepository() as $record) {
515                 $this->save($record);
516             }
517         }
518
519         // save all associations
520         foreach ($tree as $name) {
521             $table = $this->conn->getTable($name);
522
523             foreach ($table->getRepository() as $record) {
524                 $this->saveAssociations($record);
525             }
526         }
527     }
528
529     /**
530      * updates given record
531      *
532      * @param Doctrine_Record $record   record to be updated
533      * @return boolean                  whether or not the update was successful
534      */
535     public function update(Doctrine_Record $record)
536     {
537         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_UPDATE);
538
539         $record->preUpdate($event);
540
541         $table = $record->getTable();
542
543         $table->getRecordListener()->preUpdate($event);
544
545         if ( ! $event->skipOperation) {
546             $identifier = $record->identifier();
547
548             if ($table->getOption('joinedParents')) {
549                 $dataSet = $this->formatDataSet($record);
550                 
551                 $component = $table->getComponentName();
552
553                 $classes = $table->getOption('joinedParents');
554                 $classes[] = $component;
555
556                 foreach ($record as $field => $value) {
557                     if ($value instanceof Doctrine_Record) {
558                         if ( ! $value->exists()) {
559                             $value->save();
560                         }
561                         $record->set($field, $value->getIncremented());
562                     }
563                 }
564
565                 foreach ($classes as $class) {
566                     $parentTable = $this->conn->getTable($class);
567
568                     if (!array_key_exists($class, $dataSet)) {
569                         continue;
570                     }
571
572                     $this->conn->update($this->conn->getTable($class), $dataSet[$class], $identifier);
573                 }
574             } else {
575                 $array = $record->getPrepared();
576                 
577                 $this->conn->update($table, $array, $identifier);
578             }
579             $record->assignIdentifier(true);
580         }
581         
582         $table->getRecordListener()->postUpdate($event);
583
584         $record->postUpdate($event);
585
586         return true;
587     }
588     
589     /**
590      * inserts a record into database
591      *
592      * @param Doctrine_Record $record   record to be inserted
593      * @return boolean
594      */
595     public function insert(Doctrine_Record $record)
596     {
597         // listen the onPreInsert event
598         $event = new Doctrine_Event($record, Doctrine_Event::RECORD_INSERT);
599
600         $record->preInsert($event);
601         
602         $table = $record->getTable();
603
604         $table->getRecordListener()->preInsert($event);
605
606         if ( ! $event->skipOperation) {
607             if ($table->getOption('joinedParents')) {
608                 $dataSet = $this->formatDataSet($record);
609                 
610                 $component = $table->getComponentName();
611
612                 $classes = $table->getOption('joinedParents');
613                 $classes[] = $component;
614
615                 foreach ($classes as $k => $parent) {
616                     if ($k === 0) {
617                         $rootRecord = new $parent();
618
619                         $rootRecord->merge($dataSet[$parent]);
620
621                         $this->processSingleInsert($rootRecord);
622
623                         $record->assignIdentifier($rootRecord->identifier());
624                     } else {
625                         foreach ((array) $rootRecord->identifier() as $id => $value) {
626                             $dataSet[$parent][$id] = $value;
627                         }
628
629                         $this->conn->insert($this->conn->getTable($parent), $dataSet[$parent]);
630                     }
631                 }
632             } else {
633                 $this->processSingleInsert($record);
634             }
635         }
636
637         $table->addRecord($record);
638
639         $table->getRecordListener()->postInsert($event);
640
641         $record->postInsert($event);
642
643         return true;
644     }
645     
646     /**
647      * @todo DESCRIBE WHAT THIS METHOD DOES, PLEASE!
648      */
649     public function formatDataSet(Doctrine_Record $record)
650     {
651      $table = $record->getTable();
652
653         $dataSet = array();
654     
655         $component = $table->getComponentName();
656     
657         $array = $record->getPrepared();
658     
659         foreach ($table->getColumns() as $columnName => $definition) {
660             if ( ! isset($dataSet[$component])) {
661                 $dataSet[$component] = array();
662             }
663             
664             $fieldName = $table->getFieldName($columnName);
665             if (isset($definition['primary']) && $definition['primary']) {
666                 continue;
667             }
668     
669             if ( ! array_key_exists($fieldName, $array)) {
670                 continue;
671             }
672     
673             if (isset($definition['owner'])) {
674                 $dataSet[$definition['owner']][$fieldName] = $array[$fieldName];
675             } else {
676                 $dataSet[$component][$fieldName] = $array[$fieldName];
677             }
678         }
679         
680         return $dataSet;
681     }
682     
683     /**
684      * @todo DESCRIBE WHAT THIS METHOD DOES, PLEASE!
685      */
686     public function processSingleInsert(Doctrine_Record $record)
687     {
688         $fields = $record->getPrepared();
689
690         if (empty($fields)) {
691             return false;
692         }
693         
694         $table = $record->getTable();
695         $identifier = (array) $table->getIdentifier();
696
697         $seq = $record->getTable()->sequenceName;
698
699         if ( ! empty($seq)) {
700             $id = $this->conn->sequence->nextId($seq);
701             $seqName = $table->getIdentifier();
702             $fields[$seqName] = $id;
703
704             $record->assignIdentifier($id);
705         }
706
707         $this->conn->insert($table, $fields);
708
709         if (empty($seq) && count($identifier) == 1 && $identifier[0] == $table->getIdentifier() &&
710             $table->getIdentifierType() != Doctrine::IDENTIFIER_NATURAL) {
711
712             if (strtolower($this->conn->getDriverName()) == 'pgsql') {
713                 $seq = $table->getTableName() . '_' . $identifier[0];
714             }
715
716             $id = $this->conn->sequence->lastInsertId($seq);
717
718             if ( ! $id) {
719                 throw new Doctrine_Connection_Exception("Couldn't get last insert identifier.");
720             }
721
722             $record->assignIdentifier($id);
723         } else {
724             $record->assignIdentifier(true);
725         }    
726     }
727 }