. */ /** * Doctrine_Connection_UnitOfWork * * Note: This class does not have the semantics of a real "Unit of Work" in 0.10/1.0. * Database operations are not queued. All changes to objects are immediately written * to the database. You can think of it as a unit of work in auto-flush mode. * * Referential integrity is currently not always ensured. * * @package Doctrine * @subpackage Connection * @license http://www.opensource.org/licenses/lgpl-license.php LGPL * @link www.phpdoctrine.org * @since 1.0 * @version $Revision: 5438 $ * @author Konsta Vesterinen * @author Roman Borschel */ class Doctrine_Connection_UnitOfWork extends Doctrine_Connection_Module { /** * Saves the given record and all associated records. * (The save() operation is always cascaded in 0.10/1.0). * * @param Doctrine_Record $record * @return void */ public function saveGraph(Doctrine_Record $record) { $record->assignInheritanceValues(); $conn = $this->getConnection(); $state = $record->state(); if ($state === Doctrine_Record::STATE_LOCKED || $state === Doctrine_Record::STATE_TLOCKED) { return false; } $record->state($record->exists() ? Doctrine_Record::STATE_LOCKED : Doctrine_Record::STATE_TLOCKED); try { $conn->beginInternalTransaction(); $this->saveRelatedLocalKeys($record); $record->state($state); $event = $record->invokeSaveHooks('pre', 'save'); $state = $record->state(); $isValid = true; if ( ! $event->skipOperation) { switch ($state) { case Doctrine_Record::STATE_TDIRTY: case Doctrine_Record::STATE_TCLEAN: $isValid = $this->insert($record); break; case Doctrine_Record::STATE_DIRTY: case Doctrine_Record::STATE_PROXY: $isValid = $this->update($record); break; case Doctrine_Record::STATE_CLEAN: // do nothing break; } } if ($isValid) { // NOTE: what about referential integrity issues? foreach ($record->getPendingDeletes() as $pendingDelete) { $pendingDelete->delete(); } foreach ($record->getPendingUnlinks() as $alias => $ids) { if ( ! $ids) { $record->unlinkInDb($alias, array()); } else { $record->unlinkInDb($alias, array_keys($ids)); } } $record->resetPendingUnlinks(); $record->invokeSaveHooks('post', 'save', $event); } else { $conn->transaction->addInvalid($record); } $state = $record->state(); $record->state($record->exists() ? Doctrine_Record::STATE_LOCKED : Doctrine_Record::STATE_TLOCKED); $saveLater = $this->saveRelatedForeignKeys($record); foreach ($saveLater as $fk) { $alias = $fk->getAlias(); if ($record->hasReference($alias)) { $obj = $record->$alias; // check that the related object is not an instance of Doctrine_Null if ($obj && ! ($obj instanceof Doctrine_Null)) { $obj->save($conn); } } } // save the MANY-TO-MANY associations $this->saveAssociations($record); $record->state($state); $conn->commit(); } catch (Exception $e) { // Make sure we roll back our internal transaction //$record->state($state); $conn->rollback(); throw $e; } $record->clearInvokedSaveHooks(); return true; } /** * Deletes the given record and all the related records that participate * in an application-level delete cascade. * * this event can be listened by the onPreDelete and onDelete listeners * * @return boolean true on success, false on failure */ public function delete(Doctrine_Record $record) { $deletions = array(); $this->_collectDeletions($record, $deletions); return $this->_executeDeletions($deletions); } /** * Collects all records that need to be deleted by applying defined * application-level delete cascades. * * @param array $deletions Map of the records to delete. Keys=Oids Values=Records. */ private function _collectDeletions(Doctrine_Record $record, array &$deletions) { if ( ! $record->exists()) { return; } $deletions[$record->getOid()] = $record; $this->_cascadeDelete($record, $deletions); } /** * Executes the deletions for all collected records during a delete operation * (usually triggered through $record->delete()). * * @param array $deletions Map of the records to delete. Keys=Oids Values=Records. */ private function _executeDeletions(array $deletions) { // collect class names $classNames = array(); foreach ($deletions as $record) { $classNames[] = $record->getTable()->getComponentName(); } $classNames = array_unique($classNames); // order deletes $executionOrder = $this->buildFlushTree($classNames); // execute try { $this->conn->beginInternalTransaction(); for ($i = count($executionOrder) - 1; $i >= 0; $i--) { $className = $executionOrder[$i]; $table = $this->conn->getTable($className); // collect identifiers $identifierMaps = array(); $deletedRecords = array(); foreach ($deletions as $oid => $record) { if ($record->getTable()->getComponentName() == $className) { $veto = $this->_preDelete($record); if ( ! $veto) { $identifierMaps[] = $record->identifier(); $deletedRecords[] = $record; unset($deletions[$oid]); } } } if (count($deletedRecords) < 1) { continue; } // extract query parameters (only the identifier values are of interest) $params = array(); $columnNames = array(); foreach ($identifierMaps as $idMap) { while (list($fieldName, $value) = each($idMap)) { $params[] = $value; $columnNames[] = $table->getColumnName($fieldName); } } $columnNames = array_unique($columnNames); // delete $tableName = $table->getTableName(); $sql = "DELETE FROM " . $this->conn->quoteIdentifier($tableName) . " WHERE "; if ($table->isIdentifierComposite()) { $sql .= $this->_buildSqlCompositeKeyCondition($columnNames, count($identifierMaps)); $this->conn->exec($sql, $params); } else { $sql .= $this->_buildSqlSingleKeyCondition($columnNames, count($params)); $this->conn->exec($sql, $params); } // adjust state, remove from identity map and inform postDelete listeners foreach ($deletedRecords as $record) { // currently just for bc! $this->_deleteCTIParents($table, $record); //-- $record->state(Doctrine_Record::STATE_TCLEAN); $record->getTable()->removeRecord($record); $this->_postDelete($record); } } // trigger postDelete for records skipped during the deletion (veto!) foreach ($deletions as $skippedRecord) { $this->_postDelete($skippedRecord); } $this->conn->commit(); return true; } catch (Exception $e) { $this->conn->rollback(); throw $e; } } /** * Builds the SQL condition to target multiple records who have a single-column * primary key. * * @param Doctrine_Table $table The table from which the records are going to be deleted. * @param integer $numRecords The number of records that are going to be deleted. * @return string The SQL condition "pk = ? OR pk = ? OR pk = ? ..." */ private function _buildSqlSingleKeyCondition($columnNames, $numRecords) { $idColumn = $this->conn->quoteIdentifier($columnNames[0]); return implode(' OR ', array_fill(0, $numRecords, "$idColumn = ?")); } /** * Builds the SQL condition to target multiple records who have a composite primary key. * * @param Doctrine_Table $table The table from which the records are going to be deleted. * @param integer $numRecords The number of records that are going to be deleted. * @return string The SQL condition "(pk1 = ? AND pk2 = ?) OR (pk1 = ? AND pk2 = ?) ..." */ private function _buildSqlCompositeKeyCondition($columnNames, $numRecords) { $singleCondition = ""; foreach ($columnNames as $columnName) { $columnName = $this->conn->quoteIdentifier($columnName); if ($singleCondition === "") { $singleCondition .= "($columnName = ?"; } else { $singleCondition .= " AND $columnName = ?"; } } $singleCondition .= ")"; $fullCondition = implode(' OR ', array_fill(0, $numRecords, $singleCondition)); return $fullCondition; } /** * Cascades an ongoing delete operation to related objects. Applies only on relations * that have 'delete' in their cascade options. * This is an application-level cascade. Related objects that participate in the * cascade and are not yet loaded are fetched from the database. * Exception: many-valued relations are always (re-)fetched from the database to * make sure we have all of them. * * @param Doctrine_Record The record for which the delete operation will be cascaded. * @throws PDOException If something went wrong at database level * @return void */ protected function _cascadeDelete(Doctrine_Record $record, array &$deletions) { foreach ($record->getTable()->getRelations() as $relation) { if ($relation->isCascadeDelete()) { $fieldName = $relation->getAlias(); // if it's a xToOne relation and the related object is already loaded // we don't need to refresh. if ( ! ($relation->getType() == Doctrine_Relation::ONE && isset($record->$fieldName))) { $record->refreshRelated($relation->getAlias()); } $relatedObjects = $record->get($relation->getAlias()); if ($relatedObjects instanceof Doctrine_Record && $relatedObjects->exists() && ! isset($deletions[$relatedObjects->getOid()])) { $this->_collectDeletions($relatedObjects, $deletions); } else if ($relatedObjects instanceof Doctrine_Collection && count($relatedObjects) > 0) { // cascade the delete to the other objects foreach ($relatedObjects as $object) { if ( ! isset($deletions[$object->getOid()])) { $this->_collectDeletions($object, $deletions); } } } } } } /** * saveRelatedForeignKeys * saves all related (through ForeignKey) records to $record * * @throws PDOException if something went wrong at database level * @param Doctrine_Record $record */ public function saveRelatedForeignKeys(Doctrine_Record $record) { $saveLater = array(); foreach ($record->getReferences() as $k => $v) { $rel = $record->getTable()->getRelation($k); if ($rel instanceof Doctrine_Relation_ForeignKey) { $saveLater[$k] = $rel; } } return $saveLater; } /** * saveRelatedLocalKeys * saves all related (through LocalKey) records to $record * * @throws PDOException if something went wrong at database level * @param Doctrine_Record $record */ public function saveRelatedLocalKeys(Doctrine_Record $record) { foreach ($record->getReferences() as $k => $v) { $rel = $record->getTable()->getRelation($k); $local = $rel->getLocal(); $foreign = $rel->getForeign(); if ($rel instanceof Doctrine_Relation_LocalKey) { // ONE-TO-ONE relationship $obj = $record->get($rel->getAlias()); // Protection against infinite function recursion before attempting to save if ($obj instanceof Doctrine_Record && $obj->isModified()) { $obj->save($this->conn); $id = array_values($obj->identifier()); if ( ! empty($id)) { foreach ((array) $rel->getLocal() as $k => $field) { if (isset($id[$k]) && $id[$k] && $record->getTable()->hasField($field)) { $record->set($field, $id[$k]); } } } } } } } /** * saveAssociations * * this method takes a diff of one-to-many / many-to-many original and * current collections and applies the changes * * for example if original many-to-many related collection has records with * primary keys 1,2 and 3 and the new collection has records with primary keys * 3, 4 and 5, this method would first destroy the associations to 1 and 2 and then * save new associations to 4 and 5 * * @throws Doctrine_Connection_Exception if something went wrong at database level * @param Doctrine_Record $record * @return void */ public function saveAssociations(Doctrine_Record $record) { foreach ($record->getReferences() as $k => $v) { $rel = $record->getTable()->getRelation($k); if ($rel instanceof Doctrine_Relation_Association) { $v->save($this->conn, false); $assocTable = $rel->getAssociationTable(); foreach ($v->getDeleteDiff() as $r) { $query = 'DELETE FROM ' . $assocTable->getTableName() . ' WHERE ' . $rel->getForeign() . ' = ?' . ' AND ' . $rel->getLocal() . ' = ?'; $this->conn->execute($query, array($r->getIncremented(), $record->getIncremented())); } foreach ($v->getInsertDiff() as $r) { $assocRecord = $assocTable->create(); $assocRecord->set($assocTable->getFieldName($rel->getForeign()), $r); $assocRecord->set($assocTable->getFieldName($rel->getLocal()), $record); $this->saveGraph($assocRecord); } // take snapshot of collection state, so that we know when its modified again $v->takeSnapshot(); } } } /** * Invokes preDelete event listeners. * * @return boolean Whether a listener has used it's veto (don't delete!). */ private function _preDelete(Doctrine_Record $record) { $event = new Doctrine_Event($record, Doctrine_Event::RECORD_DELETE); $record->preDelete($event); $record->getTable()->getRecordListener()->preDelete($event); return $event->skipOperation; } /** * Invokes postDelete event listeners. */ private function _postDelete(Doctrine_Record $record) { $event = new Doctrine_Event($record, Doctrine_Event::RECORD_DELETE); $record->postDelete($event); $record->getTable()->getRecordListener()->postDelete($event); } /** * saveAll * persists all the pending records from all tables * * @throws PDOException if something went wrong at database level * @return void */ public function saveAll() { // get the flush tree $tree = $this->buildFlushTree($this->conn->getTables()); // save all records foreach ($tree as $name) { $table = $this->conn->getTable($name); foreach ($table->getRepository() as $record) { $this->saveGraph($record); } } } /** * updates given record * * @param Doctrine_Record $record record to be updated * @return boolean whether or not the update was successful */ public function update(Doctrine_Record $record) { $event = $record->invokeSaveHooks('pre', 'update');; if ($record->isValid(false, false)) { $table = $record->getTable(); if ( ! $event->skipOperation) { $identifier = $record->identifier(); if ($table->getOption('joinedParents')) { // currrently just for bc! $this->_updateCTIRecord($table, $record); //-- } else { $array = $record->getPrepared(); $this->conn->update($table, $array, $identifier); } $record->assignIdentifier(true); } $record->invokeSaveHooks('post', 'update', $event); return true; } return false; } /** * inserts a record into database * * @param Doctrine_Record $record record to be inserted * @return boolean */ public function insert(Doctrine_Record $record) { $event = $record->invokeSaveHooks('pre', 'insert'); if ($record->isValid(false, false)) { $table = $record->getTable(); if ( ! $event->skipOperation) { if ($table->getOption('joinedParents')) { // just for bc! $this->_insertCTIRecord($table, $record); //-- } else { $this->processSingleInsert($record); } } $table->addRecord($record); $record->invokeSaveHooks('post', 'insert', $event); return true; } return false; } /** * @todo DESCRIBE WHAT THIS METHOD DOES, PLEASE! */ public function processSingleInsert(Doctrine_Record $record) { $fields = $record->getPrepared(); $table = $record->getTable(); // Populate fields with a blank array so that a blank records can be inserted if (empty($fields)) { foreach ($table->getFieldNames() as $field) { $fields[$field] = null; } } $identifier = (array) $table->getIdentifier(); $seq = $record->getTable()->sequenceName; if ( ! empty($seq)) { $id = $this->conn->sequence->nextId($seq); $seqName = $table->getIdentifier(); $fields[$seqName] = $id; $record->assignIdentifier($id); } $this->conn->insert($table, $fields); if (empty($seq) && count($identifier) == 1 && $identifier[0] == $table->getIdentifier() && $table->getIdentifierType() != Doctrine::IDENTIFIER_NATURAL) { if (($driver = strtolower($this->conn->getDriverName())) == 'pgsql') { $seq = $table->getTableName() . '_' . $identifier[0]; } elseif ($driver == 'oracle') { $seq = $table->getTableName(); } $id = $this->conn->sequence->lastInsertId($seq); if ( ! $id) { throw new Doctrine_Connection_Exception("Couldn't get last insert identifier."); } $record->assignIdentifier($id); } else { $record->assignIdentifier(true); } } /** * buildFlushTree * builds a flush tree that is used in transactions * * The returned array has all the initialized components in * 'correct' order. Basically this means that the records of those * components can be saved safely in the order specified by the returned array. * * @param array $tables an array of Doctrine_Table objects or component names * @return array an array of component names in flushing order */ public function buildFlushTree(array $tables) { // determine classes to order. only necessary because the $tables param // can contain strings or table objects... $classesToOrder = array(); foreach ($tables as $table) { if ( ! ($table instanceof Doctrine_Table)) { $table = $this->conn->getTable($table, false); } $classesToOrder[] = $table->getComponentName(); } $classesToOrder = array_unique($classesToOrder); if (count($classesToOrder) < 2) { return $classesToOrder; } // build the correct order $flushList = array(); foreach ($classesToOrder as $class) { $table = $this->conn->getTable($class, false); $currentClass = $table->getComponentName(); $index = array_search($currentClass, $flushList); if ($index === false) { //echo "adding $currentClass to flushlist"; $flushList[] = $currentClass; $index = max(array_keys($flushList)); } $rels = $table->getRelations(); // move all foreignkey relations to the beginning foreach ($rels as $key => $rel) { if ($rel instanceof Doctrine_Relation_ForeignKey) { unset($rels[$key]); array_unshift($rels, $rel); } } foreach ($rels as $rel) { $relatedClassName = $rel->getTable()->getComponentName(); if ( ! in_array($relatedClassName, $classesToOrder)) { continue; } $relatedCompIndex = array_search($relatedClassName, $flushList); $type = $rel->getType(); // skip self-referenced relations if ($relatedClassName === $currentClass) { continue; } if ($rel instanceof Doctrine_Relation_ForeignKey) { // the related component needs to come after this component in // the list (since it holds the fk) if ($relatedCompIndex !== false) { // the component is already in the list if ($relatedCompIndex >= $index) { // it's already in the right place continue; } unset($flushList[$index]); // the related comp has the fk. so put "this" comp immediately // before it in the list array_splice($flushList, $relatedCompIndex, 0, $currentClass); $index = $relatedCompIndex; } else { $flushList[] = $relatedClassName; } } else if ($rel instanceof Doctrine_Relation_LocalKey) { // the related component needs to come before the current component // in the list (since this component holds the fk). if ($relatedCompIndex !== false) { // already in flush list if ($relatedCompIndex <= $index) { // it's in the right place continue; } unset($flushList[$relatedCompIndex]); // "this" comp has the fk. so put the related comp before it // in the list array_splice($flushList, $index, 0, $relatedClassName); } else { array_unshift($flushList, $relatedClassName); $index++; } } else if ($rel instanceof Doctrine_Relation_Association) { // the association class needs to come after both classes // that are connected through it in the list (since it holds // both fks) $assocTable = $rel->getAssociationFactory(); $assocClassName = $assocTable->getComponentName(); if ($relatedCompIndex !== false) { unset($flushList[$relatedCompIndex]); } array_splice($flushList, $index, 0, $relatedClassName); $index++; $index3 = array_search($assocClassName, $flushList); if ($index3 !== false) { if ($index3 >= $index) { continue; } unset($flushList[$index]); array_splice($flushList, $index3, 0, $assocClassName); $index = $relatedCompIndex; } else { $flushList[] = $assocClassName; } } } } return array_values($flushList); } /* The following is all the Class Table Inheritance specific code. Support dropped for 0.10/1.0. */ /** * Class Table Inheritance code. * Support dropped for 0.10/1.0. * * Note: This is flawed. We also need to delete from subclass tables. */ private function _deleteCTIParents(Doctrine_Table $table, $record) { if ($table->getOption('joinedParents')) { foreach (array_reverse($table->getOption('joinedParents')) as $parent) { $parentTable = $table->getConnection()->getTable($parent); $this->conn->delete($parentTable, $record->identifier()); } } } /** * Class Table Inheritance code. * Support dropped for 0.10/1.0. */ private function _insertCTIRecord(Doctrine_Table $table, Doctrine_Record $record) { $dataSet = $this->_formatDataSet($record); $component = $table->getComponentName(); $classes = $table->getOption('joinedParents'); $classes[] = $component; foreach ($classes as $k => $parent) { if ($k === 0) { $rootRecord = new $parent(); $rootRecord->merge($dataSet[$parent]); $this->processSingleInsert($rootRecord); $record->assignIdentifier($rootRecord->identifier()); } else { foreach ((array) $rootRecord->identifier() as $id => $value) { $dataSet[$parent][$id] = $value; } $this->conn->insert($this->conn->getTable($parent), $dataSet[$parent]); } } } /** * Class Table Inheritance code. * Support dropped for 0.10/1.0. */ private function _updateCTIRecord(Doctrine_Table $table, Doctrine_Record $record) { $identifier = $record->identifier(); $dataSet = $this->_formatDataSet($record); $component = $table->getComponentName(); $classes = $table->getOption('joinedParents'); $classes[] = $component; foreach ($record as $field => $value) { if ($value instanceof Doctrine_Record) { if ( ! $value->exists()) { $value->save(); } $record->set($field, $value->getIncremented()); } } foreach ($classes as $class) { $parentTable = $this->conn->getTable($class); if ( ! array_key_exists($class, $dataSet)) { continue; } $this->conn->update($this->conn->getTable($class), $dataSet[$class], $identifier); } } /** * Class Table Inheritance code. * Support dropped for 0.10/1.0. */ private function _formatDataSet(Doctrine_Record $record) { $table = $record->getTable(); $dataSet = array(); $component = $table->getComponentName(); $array = $record->getPrepared(); foreach ($table->getColumns() as $columnName => $definition) { if ( ! isset($dataSet[$component])) { $dataSet[$component] = array(); } $fieldName = $table->getFieldName($columnName); if (isset($definition['primary']) && $definition['primary']) { continue; } if ( ! array_key_exists($fieldName, $array)) { continue; } if (isset($definition['owner'])) { $dataSet[$definition['owner']][$fieldName] = $array[$fieldName]; } else { $dataSet[$component][$fieldName] = $array[$fieldName]; } } return $dataSet; } }