. */ /** * A Mapper is responsible for mapping between the domain model and the database * back and forth. Each entity in the domain model has a corresponding mapper. * * @author Konsta Vesterinen * @author Roman Borschel * @package Doctrine * @license http://www.opensource.org/licenses/lgpl-license.php LGPL * @version $Revision: 3406 $ * @link www.phpdoctrine.org * @since 2.0 * @todo Move all finder stuff to EntityRepository. * @todo Rename to "EntityPersister" or similar. */ class Doctrine_Mapper { /** * Metadata object that descibes the mapping of the mapped entity class. * * @var Doctrine_ClassMetadata */ protected $_classMetadata; /** * The name of the domain class this mapper is used for. */ protected $_domainClassName; /** * The Doctrine_Connection object that the database connection of this mapper. * * @var Doctrine_Connection $conn */ protected $_conn; /** * The concrete mapping strategy that is used. */ protected $_mappingStrategy; /** * @var array $identityMap first level cache * @todo Move to UnitOfWork. */ protected $_identityMap = array(); /** * Null object. */ private $_nullObject; /** * A list of registered entity listeners. */ private $_entityListeners = array(); /** * Constructs a new mapper. * * @param string $name The name of the domain class this mapper is used for. * @param Doctrine_Table $table The table object used for the mapping procedure. * @throws Doctrine_Connection_Exception if there are no opened connections */ public function __construct($name, Doctrine_ClassMetadata $classMetadata) { $this->_domainClassName = $name; $this->_conn = $classMetadata->getConnection(); $this->_classMetadata = $classMetadata; $this->_nullObject = Doctrine_Null::$INSTANCE; if ($classMetadata->getInheritanceType() == Doctrine::INHERITANCE_TYPE_JOINED) { $this->_mappingStrategy = new Doctrine_Mapper_JoinedStrategy($this); } else { $this->_mappingStrategy = new Doctrine_Mapper_DefaultStrategy($this); } } /** * createQuery * creates a new Doctrine_Query object and adds the component name * of this table as the query 'from' part * * @param string Optional alias name for component aliasing. * * @return Doctrine_Query */ public function createQuery($alias = '') { if ( ! empty($alias)) { $alias = ' ' . trim($alias); } return Doctrine_Query::create($this->_conn)->from($this->getComponentName() . $alias); } /** * sets the connection for this class * * @params Doctrine_Connection a connection object * @return Doctrine_Table this object * @todo refactor */ public function setConnection(Doctrine_Connection $conn) { $this->_conn = $conn; return $this; } /** * Returns the connection the mapper is currently using. * * @return Doctrine_Connection|null The connection object. */ public function getConnection() { return $this->_conn; } /** * creates a new record * * @param $array an array where keys are field names and * values representing field values * @return Doctrine_Record the created record object */ public function create(array $array = array()) { $record = new $this->_domainClassName($this, true); $record->fromArray($array); return $record; } public function addEntityListener(Doctrine_Record_Listener $listener) { if ( ! in_array($listener, $this->_entityListeners)) { $this->_entityListeners[] = $listener; return true; } return false; } public function removeEntityListener(Doctrine_Record_Listener $listener) { if ($key = array_search($listener, $this->_entityListeners, true)) { unset($this->_entityListeners[$key]); return true; } return false; } public function notifyEntityListeners(Doctrine_Record $entity, $callback, $eventType) { if ($this->_entityListeners) { $event = new Doctrine_Event($entity, $eventType); foreach ($this->_entityListeners as $listener) { $listener->$callback($event); } } } public function detach(Doctrine_Record $entity) { return $this->_conn->unitOfWork->detach($entity); } /** * Finds an entity by its primary key. * * @param $id database row id * @param int $hydrationMode Doctrine::HYDRATE_ARRAY or Doctrine::HYDRATE_RECORD * @return mixed Array or Doctrine_Record or false if no result * @todo Remove. Move to EntityRepository. */ public function find($id, $hydrationMode = null) { if (is_null($id)) { return false; } $id = is_array($id) ? array_values($id) : array($id); return $this->createQuery() ->where(implode(' = ? AND ', (array) $this->_classMetadata->getIdentifier()) . ' = ?') ->fetchOne($id, $hydrationMode); } /** * Finds all entities of the mapper's class. * Use with care. * * @param int $hydrationMode Doctrine::HYDRATE_ARRAY or Doctrine::HYDRATE_RECORD * @return Doctrine_Collection * @todo Remove. Move to EntityRepository. */ public function findAll($hydrationMode = null) { return $this->createQuery()->execute(array(), $hydrationMode); } /** * findBySql * finds records with given SQL where clause * returns a collection of records * * @param string $dql DQL after WHERE clause * @param array $params query parameters * @param int $hydrationMode Doctrine::FETCH_ARRAY or Doctrine::FETCH_RECORD * @return Doctrine_Collection * * @todo This actually takes DQL, not SQL, but it requires column names * instead of field names. This should be fixed to use raw SQL instead. * @todo Remove. Move to EntityRepository. */ public function findBySql($dql, array $params = array(), $hydrationMode = null) { return $this->createQuery()->where($dql)->execute($params, $hydrationMode); } /** * findByDql * finds records with given DQL where clause * returns a collection of records * * @param string $dql DQL after WHERE clause * @param array $params query parameters * @param int $hydrationMode Doctrine::FETCH_ARRAY or Doctrine::FETCH_RECORD * @return Doctrine_Collection * @todo Remove. Move to EntityRepository. */ public function findByDql($dql, array $params = array(), $hydrationMode = null) { $query = new Doctrine_Query($this->_conn); $component = $this->getComponentName(); $dql = 'FROM ' . $component . ' WHERE ' . $dql; return $query->query($dql, $params, $hydrationMode); } /** * Executes a named query. * * @param string $queryName The name that was used when storing the query. * @param array $params The query parameters. * @return mixed The result. * @deprecated */ public function executeNamedQuery($queryName, $params = array(), $hydrationMode = Doctrine::HYDRATE_RECORD) { return Doctrine_Manager::getInstance() ->createNamedQuery($queryName) ->execute($params, $hydrationMode); } /** * clear * clears the first level cache (identityMap) * * @return void * @todo what about a more descriptive name? clearIdentityMap? */ public function clear() { $this->_identityMap = array(); //$this->_conn->unitOfWork->clearIdentitiesForEntity($this->_classMetadata->getRootClassName()); } /** * addRecord * adds a record to identity map * * @param Doctrine_Record $record record to be added * @return boolean * @todo Better name? registerRecord? Move elsewhere to the new location of the identity maps. */ public function addRecord(Doctrine_Record $record) { $id = implode(' ', $record->identifier()); if (isset($this->_identityMap[$id])) { return false; } /*if ($this->_conn->unitOfWork->containsIdentity($id, $record->getClassMetadata()->getRootClassname())) { return false; }*/ //$this->_conn->unitOfWork->registerIdentity($record); $this->_identityMap[$id] = $record; return true; } /** * Tells the mapper to manage the entity if it's not already managed. * * @return boolean TRUE if the entity was previously not managed and is now managed, * FALSE otherwise (the entity is already managed). */ public function manage(Doctrine_Record $record) { return $this->_conn->unitOfWork->manage($record); } /** * removeRecord * removes a record from the identity map, returning true if the record * was found and removed and false if the record wasn't found. * * @param Doctrine_Record $record record to be removed * @return boolean * @todo Move elsewhere to the new location of the identity maps. */ public function removeRecord(Doctrine_Record $record) { $id = implode(' ', $record->identifier()); if (isset($this->_identityMap[$id])) { unset($this->_identityMap[$id]); return true; } /*if ($this->_conn->unitOfWork->containsIdentity($id, $record->getClassMetadata()->getRootClassName())) { $this->_conn->unitOfWork->unregisterIdentity($record); return true; }*/ return false; } /** * getRecord * First checks if record exists in identityMap, if not * returns a new record. * * @return Doctrine_Record */ public function getRecord(array $data) { if ( ! empty($data)) { $identifierFieldNames = (array)$this->_classMetadata->getIdentifier(); $found = false; foreach ($identifierFieldNames as $fieldName) { if ( ! isset($data[$fieldName])) { // primary key column not found return new record $found = true; break; } $id[] = $data[$fieldName]; } if ($found) { $record = new $this->_domainClassName($this, true, $data); $data = array(); return $record; } $id = implode(' ', $id); if (isset($this->_identityMap[$id])) { //if ($this->_conn->unitOfWork->containsIdentity($id, $this->_classMetadata->getRootClassName())) { $record = $this->_identityMap[$id]; //$record = $this->_conn->unitOfWork->getByIdentity($id, $this->_classMetadata->getRootClassName()); $record->hydrate($data); } else { $record = new $this->_domainClassName($this, false, $data); //$this->_conn->unitOfWork->registerIdentity($record); $this->_identityMap[$id] = $record; } $data = array(); } else { $record = new $this->_domainClassName($this, true, $data); } return $record; } /** * @param $id database row id */ final public function getProxy($id = null) { if ($id !== null) { $identifierColumnNames = $this->_classMetadata->getIdentifierColumnNames(); $query = 'SELECT ' . implode(', ', $identifierColumnNames) . ' FROM ' . $this->_classMetadata->getTableName() . ' WHERE ' . implode(' = ? && ', $identifierColumnNames) . ' = ?'; $query = $this->applyInheritance($query); $params = array_merge(array($id),array()); $data = $this->_conn->execute($query, $params)->fetch(PDO::FETCH_ASSOC); if ($data === false) { return false; } } return $this->getRecord($data); } /** * applyInheritance * @param $where query where part to be modified * @return string query where part with column aggregation inheritance added */ final public function applyInheritance($where) { $discCol = $this->_classMetadata->getInheritanceOption('discriminatorColumn'); if ( ! $discCol) { return $where; } $discMap = $this->_classMetadata->getInheritanceOption('discriminatorMap'); $inheritanceMap = array($discCol => array_search($this->_domainClassName, $discMap)); if ( ! empty($inheritanceMap)) { $a = array(); foreach ($inheritanceMap as $column => $value) { $a[] = $column . ' = ?'; } $i = implode(' AND ', $a); $where .= ' AND ' . $i; } return $where; } /** * prepareValue * this method performs special data preparation depending on * the type of the given column * * 1. It unserializes array and object typed columns * 2. Uncompresses gzip typed columns * 3. Gets the appropriate enum values for enum typed columns * 4. Initializes special null object pointer for null values (for fast column existence checking purposes) * * example: * * $field = 'name'; * $value = null; * $table->prepareValue($field, $value); // Doctrine_Null * * * @throws Doctrine_Table_Exception if unserialization of array/object typed column fails or * @throws Doctrine_Table_Exception if uncompression of gzip typed column fails * * @param string $field the name of the field * @param string $value field value * @param string $typeHint A hint on the type of the value. If provided, the type lookup * for the field can be skipped. Used i.e. during hydration to * improve performance on large and/or complex results. * @return mixed prepared value */ public function prepareValue($fieldName, $value, $typeHint = null) { if ($value === $this->_nullObject) { return $this->_nullObject; } else if ($value === null) { return null; } else { $type = is_null($typeHint) ? $this->_classMetadata->getTypeOf($fieldName) : $typeHint; switch ($type) { case 'integer': case 'string'; // don't do any casting here PHP INT_MAX is smaller than what the databases support break; case 'enum': return $this->_classMetadata->enumValue($fieldName, $value); break; case 'boolean': return (boolean) $value; break; case 'array': case 'object': if (is_string($value)) { $value = unserialize($value); if ($value === false) { throw new Doctrine_Mapper_Exception('Unserialization of ' . $fieldName . ' failed.'); } return $value; } break; case 'gzip': $value = gzuncompress($value); if ($value === false) { throw new Doctrine_Mapper_Exception('Uncompressing of ' . $fieldName . ' failed.'); } return $value; break; } } return $value; } /** * Hydrates the given data into the entity. * */ public function hydrate(Doctrine_Record $entity, array $data) { $this->_values = array_merge($this->_values, $this->cleanData($data)); $this->_data = array_merge($this->_data, $data); $this->_extractIdentifier(true); } /** * getTree * * getter for associated tree * * @return mixed if tree return instance of Doctrine_Tree, otherwise returns false * @todo Part of the NestedSet Behavior plugin. Move outta here some day... */ public function getTree() { return $this->_classMetadata->getTree(); } /** * isTree * * determine if table acts as tree * * @return mixed if tree return true, otherwise returns false * @todo Part of the NestedSet Behavior plugin. Move outta here some day... */ public function isTree() { return $this->_classMetadata->isTree(); } /** * getComponentName * * @return void * @deprecated Use getMappedClassName() */ public function getComponentName() { return $this->_domainClassName; } /** * Gets the name of the class the mapper is used for. */ public function getMappedClassName() { return $this->_domainClassName; } /** * returns a string representation of this object * * @return string */ public function __toString() { return Doctrine_Lib::getTableAsString($this); } /** * findBy * * @param string $column * @param string $value * @param string $hydrationMode * @return void * @todo Remove. Move to EntityRepository. */ protected function findBy($fieldName, $value, $hydrationMode = null) { return $this->createQuery()->where($fieldName . ' = ?')->execute(array($value), $hydrationMode); } /** * findOneBy * * @param string $column * @param string $value * @param string $hydrationMode * @return void * @todo Remove. Move to EntityRepository. */ protected function findOneBy($fieldName, $value, $hydrationMode = null) { $results = $this->createQuery()->where($fieldName . ' = ?')->limit(1)->execute( array($value), $hydrationMode); return $hydrationMode === Doctrine::HYDRATE_ARRAY ? array_shift($results) : $results->getFirst(); } /** * __call * * Adds support for magic finders. * findByColumnName, findByRelationAlias * findById, findByContactId, etc. * * @return void * @throws Doctrine_Mapper_Exception If the method called is an invalid find* method * or no find* method at all and therefore an invalid * method call. * @todo Remove. Move to EntityRepository. */ public function __call($method, $arguments) { if (substr($method, 0, 6) == 'findBy') { $by = substr($method, 6, strlen($method)); $method = 'findBy'; } else if (substr($method, 0, 9) == 'findOneBy') { $by = substr($method, 9, strlen($method)); $method = 'findOneBy'; } else { try { throw new Exception(); } catch (Exception $e) { echo $e->getTraceAsString() . "

"; } throw new Doctrine_Mapper_Exception("Undefined method '$method'."); } if (isset($by)) { if ( ! isset($arguments[0])) { throw new Doctrine_Mapper_Exception('You must specify the value to findBy.'); } $fieldName = Doctrine::tableize($by); $hydrationMode = isset($arguments[1]) ? $arguments[1]:null; if ($this->_classMetadata->hasField($fieldName)) { return $this->$method($fieldName, $arguments[0], $hydrationMode); } else if ($this->_classMetadata->hasRelation($by)) { $relation = $this->_classMetadata->getRelation($by); if ($relation['type'] === Doctrine_Relation::MANY) { throw new Doctrine_Mapper_Exception('Cannot findBy many relationship.'); } return $this->$method($relation['local'], $arguments[0], $hydrationMode); } else { throw new Doctrine_Mapper_Exception('Cannot find by: ' . $by . '. Invalid field or relationship alias.'); } } } /** * Saves an entity and all it's related entities. * * @param Doctrine_Record $record The entity to save. * @param Doctrine_Connection $conn The connection to use. Will default to the mapper's * connection. * @throws Doctrine_Mapper_Exception If the mapper is unable to save the given entity. */ public function save(Doctrine_Record $record, Doctrine_Connection $conn = null) { if ( ! ($record instanceof $this->_domainClassName)) { throw new Doctrine_Mapper_Exception("Mapper of type " . $this->_domainClassName . " can't save instances of type" . get_class($record) . "."); } if ($conn === null) { $conn = $this->_conn; } $state = $record->state(); if ($state === Doctrine_Record::STATE_LOCKED) { return false; } $record->state(Doctrine_Record::STATE_LOCKED); try { $conn->beginInternalTransaction(); $saveLater = $this->_saveRelated($record); $record->state($state); if ($record->isValid()) { $this->_insertOrUpdate($record); } else { $conn->transaction->addInvalid($record); } $state = $record->state(); $record->state(Doctrine_Record::STATE_LOCKED); 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 instanceof Doctrine_Null)) { $obj->save($conn); } } } // save the MANY-TO-MANY associations $this->saveAssociations($record); // reset state $record->state($state); $conn->commit(); } catch (Exception $e) { $conn->rollback(); throw $e; } return true; } /** * Inserts or updates an entity, depending on it's state. * * @param Doctrine_Record $record The entity to insert/update. */ protected function _insertOrUpdate(Doctrine_Record $record) { $record->preSave(); $this->notifyEntityListeners($record, 'preSave', Doctrine_Event::RECORD_SAVE); switch ($record->state()) { case Doctrine_Record::STATE_TDIRTY: $this->_insert($record); break; case Doctrine_Record::STATE_DIRTY: case Doctrine_Record::STATE_PROXY: $this->_update($record); break; case Doctrine_Record::STATE_CLEAN: case Doctrine_Record::STATE_TCLEAN: // do nothing break; } $record->postSave(); $this->notifyEntityListeners($record, 'postSave', Doctrine_Event::RECORD_SAVE); } /** * saves the given record * * @param Doctrine_Record $record * @return void */ public function saveSingleRecord(Doctrine_Record $record) { $this->_insertOrUpdate($record); } /** * _saveRelated * saves all related records to $record * * @throws PDOException if something went wrong at database level * @param Doctrine_Record $record */ protected function _saveRelated(Doctrine_Record $record) { $saveLater = array(); foreach ($record->getReferences() as $k => $v) { $rel = $record->getTable()->getRelation($k); $local = $rel->getLocal(); $foreign = $rel->getForeign(); if ($rel instanceof Doctrine_Relation_ForeignKey) { $saveLater[$k] = $rel; } else 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); /** Can this be removed? $id = array_values($obj->identifier()); foreach ((array) $rel->getLocal() as $k => $field) { $record->set($field, $id[$k]); } */ } } } return $saveLater; } /** * 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 $relationName => $relatedObject) { $rel = $record->getTable()->getRelation($relationName); if ($rel instanceof Doctrine_Relation_Association) { $relatedObject->save($this->_conn); $assocTable = $rel->getAssociationTable(); foreach ($relatedObject->getDeleteDiff() as $r) { $query = 'DELETE FROM ' . $assocTable->getTableName() . ' WHERE ' . $rel->getForeign() . ' = ?' . ' AND ' . $rel->getLocal() . ' = ?'; $this->_conn->execute($query, array($r->getIncremented(), $record->getIncremented())); } $assocMapper = $this->_conn->getMapper($assocTable->getComponentName()); foreach ($relatedObject->getInsertDiff() as $r) { $assocRecord = $assocMapper->create(); $assocRecord->set($assocTable->getFieldName($rel->getForeign()), $r); $assocRecord->set($assocTable->getFieldName($rel->getLocal()), $record); $assocMapper->save($assocRecord); //$this->saveSingleRecord($assocRecord); } } } } /** * Updates an entity. * * @param Doctrine_Record $record record to be updated * @return boolean whether or not the update was successful * @todo Move to Doctrine_Table (which will become Doctrine_Mapper). */ protected function _update(Doctrine_Record $record) { $record->preUpdate(); $this->notifyEntityListeners($record, 'preUpdate', Doctrine_Event::RECORD_UPDATE); $table = $this->_classMetadata; $this->_mappingStrategy->doUpdate($record); $record->postUpdate(); $this->notifyEntityListeners($record, 'postUpdate', Doctrine_Event::RECORD_UPDATE); return true; } /** * Inserts an entity. * * @param Doctrine_Record $record record to be inserted * @return boolean */ protected function _insert(Doctrine_Record $record) { $record->preInsert(); $this->notifyEntityListeners($record, 'preInsert', Doctrine_Event::RECORD_INSERT); $this->_mappingStrategy->doInsert($record); $this->addRecord($record); $record->postInsert(); $this->notifyEntityListeners($record, 'postInsert', Doctrine_Event::RECORD_INSERT); return true; } /** * Deletes given entity and all it's related entities. * * Triggered Events: onPreDelete, onDelete. * * @return boolean true on success, false on failure * @throws Doctrine_Mapper_Exception */ public function delete(Doctrine_Record $record, Doctrine_Connection $conn = null) { if ( ! $record->exists()) { return false; } if ( ! ($record instanceof $this->_domainClassName)) { throw new Doctrine_Mapper_Exception("Mapper of type " . $this->_domainClassName . " can't save instances of type" . get_class($record) . "."); } if ($conn == null) { $conn = $this->_conn; } $record->preDelete(); $this->notifyEntityListeners($record, 'preDelete', Doctrine_Event::RECORD_DELETE); $table = $this->_classMetadata; $state = $record->state(); $record->state(Doctrine_Record::STATE_LOCKED); $this->_mappingStrategy->doDelete($record); $record->postDelete(); $this->notifyEntityListeners($record, 'postDelete', Doctrine_Event::RECORD_DELETE); return true; } public function hasAttribute($key) { switch ($key) { case Doctrine::ATTR_LOAD_REFERENCES: case Doctrine::ATTR_QUERY_LIMIT: case Doctrine::ATTR_COLL_KEY: case Doctrine::ATTR_VALIDATE: return true; default: return false; } } public function executeQuery(Doctrine_Query $query) { } public function getTable() { return $this->_classMetadata; } public function getClassMetadata() { return $this->_classMetadata; } public function getIdentityMap() { return $this->_identityMap; } public function dump() { var_dump($this->_invokedMethods); } public function free() { $this->_mappingStrategy = null; } public function getMapping() { return $this->_mappingStrategy; } public function getFieldName($columnName) { return $this->_mappingStrategy->getFieldName($columnName); } public function getFieldNames() { return $this->_mappingStrategy->getFieldNames(); } public function getOwningClass($fieldName) { return $this->_mappingStrategy->getOwningClass($fieldName); } /* Hooks used during SQL query construction to manipulate the query. */ /** * Callback that is invoked during the SQL construction process. */ public function getCustomJoins() { return $this->_mappingStrategy->getCustomJoins(); } /** * Callback that is invoked during the SQL construction process. */ public function getCustomFields() { return $this->_mappingStrategy->getCustomFields(); } }