Location: PHPKode > projects > Outlet > classes/outlet/OutletMapper.php
<?php
/**
 * Data Mapper that acts as the interface between Outlet and the Database
 * @package outlet
 */
class OutletMapper {
	const IDENTIFIER_PATTERN = '/\{[a-zA-Z0-9_]+(( |\.)[a-zA-Z0-9_]+)*\}/';

	//static $conf;
	
	/**
	 * @var array
	 */
	public $map = array();
	
	/**
	 * @var OutletConfig
	 */
	private $config;
	

	
	public $onHydrate;

	/**
	 * Constructs a new instance of OutletMapper
	 * @param OutletConfig $config Configuration to use
	 * @return OutletMapper instance
	 */
	function __construct (OutletConfig $config) {
		$this->config = $config;
	}

	/**
	 * Return the entity class of an object as defined in the config
	 * 
	 * For example, it will return 'User' when passed an instance of User or User_OutletProxy
	 * 
	 * @param object $obj The object to introspect
	 * @return string the entity classname
	 */
	static function getEntityClass ($obj) {
		if ($obj instanceof OutletProxy) {
			return substr(get_class($obj), 0, -(strlen('_OutletProxy')));
		} else {
			return get_class($obj);
		}
	}

	/**
	 * Persist an entity to the database by performing either an INSERT or an UPDATE
	 * 
	 * @see OutletMapper::insert(&$obj)
	 * @see OutletMapper::update(&$obj)
	 * @param object $obj the entity to save
	 */
	public function save (&$obj) {
		$entityCfg = $this->config->getEntityForObject($obj);

		if (self::isNew($obj)) {
			return $this->insert($obj, $entityCfg);
		} else {
			return $this->update($obj, $entityCfg);
		}
	}

	/**
	 * Determine if an object has been saved by seeing if it's actually a proxy
	 * 
	 * @param object $obj the entity to check
	 * @return bool true if entity is new, false otherwise
	 */
	static function isNew($obj) {
		return ! $obj instanceof OutletProxy;
	}

	/**
	 * Set the primary key of an entity
	 * 
	 * @param object $obj the entity on which to set the primary key
	 * @param mixed $pk scalar or array for columnar or composite primary key
	 */
	public function setPk ($obj, $pk) {
		if (!is_array($pk)) $pk = array($pk);

		$pk_props = $this->config->getEntity($this->config->getEntityClass($obj))->getPkFields();

		if (count($pk)!=count($pk_props)) throw new OutletException('You must pass the following pk: ['.implode(',', $pk_props).'], you passed: ['.implode(',', $pk).']');

		foreach ($pk_props as $key=>$prop) {
			$obj->$prop = $pk[$key];
		}
	}

    /**
     * Loads an entity by its primary key
     * 
     * @param string $cls Entity class
     * @param mixed $pk Primary key
     * @return object the entity, if the entity can't be found returns null
     */
	public function load ($cls, $pk) {
		if (!$pk) {
			throw new OutletException("Must pass a valid primary key value, passed: ".var_export($pk, true));
		}
	   
		if (!is_array($pk)) {
			$pks = array($pk);
		} else {
			$pks = $pk;
		} 

		// try to retrieve it from the cache first
		$data = $this->get($cls, $pks);

		// if it's there
		if ($data) {
			$obj = $data['obj'];

		// else, populate it from the database
		} else {
			$entityCfg = $this->config->getEntity($cls);

			// create a proxy
			$proxyclass = "{$cls}_OutletProxy";

			$obj = new $proxyclass;

			$props_conf = $entityCfg->getProperties();
			
			$props = array();
			foreach ($props_conf as $key=>$conf) {
				// if it's sql we must specify an alias
				if (isset($conf[2]) && isset($conf[2]['sql'])) {
					$props[] = '{'.$cls.'.'.$key.'} as ' . $conf[0];
				} else {
					$props[] = '{'.$cls.'.'.$key.'}';
				}
			}

			// craft select
			$q = "SELECT ";
			$q .= implode(', ', $props) . "\n";
			$q .= "FROM {".$cls."} \n";

			$pk_props = $entityCfg->getPkFields();

			$pk_q = array();
			foreach ($pk_props as $pkp) {
				$pk_q[] = '{'.$cls.'.'.$pkp.'} = ?';
			}

			$q .= "WHERE " . implode(' AND ', $pk_q);

			$q = $this->processQuery($q);

			$stmt = $this->config->getConnection()->prepare($q);

			$stmt->execute(array_values($pks));

			$row = $stmt->fetch(PDO::FETCH_ASSOC);

			// if there's no matching row,
			// return null
			if (!$row) {
				return null;
			}
		
			$entityCfg->castRow($row);	
			$this->populateObject($cls, $obj, $row);

			// add it to the cache
			$this->set($cls, $entityCfg->getPkValues($obj), array(
				'obj' => $obj,
				'original' => $entityCfg->toRow($obj)
			));
		}

		return $obj;
	}
	
	/**
	 * Populate an object with the values from an associative array indexed by column names
	 * 
	 * @param array $clazz Class of the entity
	 * @param object $obj Instance of the entity (probably brand new) or a subclass
	 * @param array $values Associative array indexed by column name, it must already be casted
	 * @return object populated entity 
	 */
	public function populateObject ($clazz, $obj, array $values) {
		$entityCfg = $this->config->getEntity($clazz);

		$entityCfg->populateObject ($obj, $values);

		// trigger onHydrate callback
		if ($this->onHydrate) {
			call_user_func($this->onHydrate, $obj);
		}

		return $obj;
	}

	/**
	 * Retrieve the primary key property of an entity
	 * @param object $obj the entity
	 * @return object the primary key property
	 */
	public function getPkProp ( $obj ) {
		return $this->config->getEntity($this->config->getEntityClass($obj))->getPkField();
	}

	/**
	 * Saves the one to many relationships for an entity
	 * @param object $obj entity to save one to many relationship for
	 */
	public function saveOneToMany ($obj, OutletEntityConfig $entityCfg) {
		$assocs = $entityCfg->getAssociations();

		if (count($assocs)) {
			$pks = $entityCfg->getPkValues($obj);

			foreach ($assocs as $assoc) {
				
				if ($assoc->getType() != 'one-to-many') {
					// only process one-to-many relationships
					continue;
				}

				$key 		= $assoc->getKey();
				$getter 	= $assoc->getGetter();
				$setter		= $assoc->getSetter();
				$foreign	= $assoc->getForeign();

				/** @var $children Collection */
				$children = $obj->$getter(null);

				if (is_null($children)) {
					continue;
				}
				
				// if we don't have an OutletCollection yet
				if (! $children instanceof OutletCollection) {
					$arr = $children->getArrayCopy();

					/** @var $children OutletCollection */
					$children = $obj->$getter();
					$children->exchangeArray($arr);
				}

				// if removing all connections
				if ($children->isRemoveAll()) {	
					/** @todo Make it work with composite keys */
					$q = $this->processQuery('DELETE FROM {'.$foreign.'} WHERE {'.$foreign.'.'.$assoc->getKey().'} = ?');
					$stmt = $this->config->getConnection()->prepare($q);
					$stmt->execute($pks);
				}

				foreach (array_keys($children->getArrayCopy()) as $k) {
					/** @todo make it work with composite keys */
					$foreignEntityCfg = $this->config->getEntity($foreign);
					$foreignEntityCfg->setProp($children[$k], $key, current($pks));
					$this->save($children[$k]);
				}

				$obj->$setter( $children );
			}
		}
	}

	/**
	 * Saves the many to many relationships for an entity
	 * @param object $obj entity to save many to many relationship for
	 */
	private function saveManyToMany ($obj, OutletEntityConfig $entityCfg) {
		$con = $this->config->getConnection();
		$assocs = $entityCfg->getAssociations();

		if (count($assocs)) {
			$pks = $entityCfg->getPkValues($obj);

			foreach ($assocs as $assoc) {
				if ($assoc->getType() != 'many-to-many') {
					// only process 'many-to-many' relationships
					continue;
				}

				$key_column = $assoc->getTableKeyLocal();
				$ref_column = $assoc->getTableKeyForeign();
				$table      = $assoc->getLinkingTable();
				$name       = $assoc->getForeignName();

				$getter	= $assoc->getGetter();
				$setter	= $assoc->getSetter();

				$children = $obj->$getter();

				// if removing all connections
				if ($children->isRemoveAll()) {
					/** @todo Make it work with composite keys */
					$q = "DELETE FROM $table WHERE $key_column = ?";

					$stmt = $con->prepare($q);

					$stmt->execute(array_values($pks));
				}

				$new = $children->getLocalIterator();

				foreach ($new as $child) {
					if ($child instanceof OutletProxy) {
						$child_pks = $this->config->getEntityForObject($child)->getPkValues($child);
						$id = current($child_pks);
					} else {
						$id = $child;
					}

					$q = "
						INSERT INTO $table ($key_column, $ref_column) 
						VALUES (?, ?)
					";

					$stmt = $con->prepare($q);

					$stmt->execute(array(current($pks), $id));
				}

				$obj->$setter( $children );
			}
		}
	}

	/**
	 * Saves the many to one relationships for an entity
	 * @param object $obj entity to save many to one relationship for
	 */ 
	private function saveManyToOne ($obj, OutletEntityConfig $entityCfg) {
		foreach ($entityCfg->getAssociations() as $assoc) {
			if ($assoc->getType() != 'many-to-one') {
				// only process 'many-to-one' relationships
				continue;
			}

			$key    = $assoc->getKey();
			$refKey	= $assoc->getRefKey();
			$getter = $assoc->getGetter();

			$ent = $obj->$getter();

			if ($ent) {
				if (self::isNew($ent)) {
					$this->save($ent);
				} 

				$foreignEntityCfg = $this->config->getEntityForObject($ent);

				$entityCfg->setProp($obj, $key, $foreignEntityCfg->getProp($ent, $refKey));
			}
		}
	}

	/**
	 * Saves the one to one relationships for an entity
	 * @param object $obj entity to save one to one relationship for
	 */ 
	function saveOneToOne ($obj, OutletEntityConfig $entityCfg) {
		foreach ($entityCfg->getAssociations() as $assoc) {
			if ($assoc->getType() != 'one-to-one') {
				// only process 'one-to-one' relationships
				continue;
			}

			$key    = $assoc->getKey();
			$refKey	= $assoc->getRefKey();
			$getter = $assoc->getGetter();

			$ent = $obj->$getter();

			if ($ent) {
				if (self::isNew($ent)) { 
					$this->save($ent);
				}

				$obj->$key = $ent->$refKey;
			}
		}
	}

	/**
	 * Inserts an entity into the database including relationships.
	 * 
	 * It is safer to use the save function, it will take into account newness of the entity to determine insert vs update
	 * 
	 * @see OutletMapper::save(&$obj)
	 * @param object $obj entity to insert
	 */
	public function insert (&$obj, OutletEntityConfig $entityCfg) {
		$con = $this->config->getConnection();

		$this->saveOneToOne( $obj, $entityCfg );
		$this->saveManyToOne( $obj, $entityCfg );

		$properties = $entityCfg->getProperties();

		$props = array_keys($properties);
		$table = $entityCfg->table;

		// grab insert fields
		$insert_fields = array();
		$insert_props = array();
		$insert_defaults = array();

		foreach ($entityCfg->getProperties() as $prop=>$f) {
			
			if (isset($f[2]) && isset($f[2]['autoIncrement']) && $f[2]['autoIncrement']) {
				// skip autoIncrement fields
				continue;
			}

			$insert_props[] = $prop;
			$insert_fields[] = $f[0];

			// if there's options
			/** @todo Clean this up */
			if (isset($f[2])) {
				if (is_null( $entityCfg->getProp( $obj, $prop ) )) {
					if (isset($f[2]['default'])) {
						$entityCfg->setProp( $obj, $prop, $f[2]['default']);
						$insert_defaults[] = false;
					} elseif (isset($f[2]['defaultExpr'])) {
						$insert_defaults[] = $f[2]['defaultExpr'];
					} else {
						$insert_defaults[] = false;
					}
				} else {
					$insert_defaults[] = false;
				}
				continue;
			} else {
				$insert_defaults[] = false;
			}
		}

		$q = "INSERT INTO $table ";
		$q .= "(" . implode(', ', $insert_fields) . ")";
		$q .= " VALUES ";

		// question marks for each value
		// except for defaults
		$values = array();
		foreach ($insert_fields as $key=>$f) {
			if ($insert_defaults[$key]) {
				$values[] = $insert_defaults[$key];
			} else {
				$values[] = '?';
			}
		}
		$q .="(" . implode(', ', $values) . ")";

		$stmt = $con->prepare($q);

		// get the values
		$values = array();
		foreach ($insert_props as $key=>$p) {
			if ($insert_defaults[$key]) {
				// skip the defaults
				continue;
			}

			$values[] = self::toSqlValue( $properties[$p][1], $entityCfg->getProp($obj, $p) );
		}

		$stmt->execute($values);

		// create a proxy
		$proxy_class = $entityCfg->clazz . '_OutletProxy';
		$proxy = new $proxy_class;

		// copy the properties to the proxy
		foreach ($entityCfg->getProperties() as $key=>$f) {
			$field = $key;
			if (@$f[2]['autoIncrement']) {
				// Sequence name will be set and is needed for Postgres
				$id = $con->lastInsertId($entityCfg->getSequenceName());
				$entityCfg->setProp( $proxy, $field , self::toPhpValue($f, $id) );
			} else {
				$entityCfg->setProp( $proxy, $field , $entityCfg->getProp( $obj, $field ) );
			}
		}

		// copy the associated objects to the proxy
		foreach ($entityCfg->getAssociations() as $a) {
			$type = $a->getType();
			if ($type == 'one-to-many' || $type == 'many-to-many') {
				$getter = $a->getGetter();
				$setter	= $a->getSetter();

				$ref = $obj->$getter();
				if ($ref) $proxy->$setter( $obj->$getter() );
			}
		}
		$obj = $proxy;

		$this->saveOneToMany($obj, $entityCfg);
		$this->saveManyToMany($obj, $entityCfg);
			
		// trigger onHydrate callback
		if ($this->onHydrate) {
			call_user_func($this->onHydrate, $obj);
		}

		// add it to the cache
		self::set($entityCfg->clazz, $entityCfg->getPkValues($obj), array(
			'obj' => $obj,
			'original' => $entityCfg->toRow($obj)
		));
	}

	/**
	 * Check to see if an entity values (row) have been modified
	 *
	 * @param object $obj entity to inspect
	 * @return array fields that have changed
	 */
	public function getModifiedFields ($obj) {
		$entityCfg = $this->config->getEntityForObject($obj);

		$data = $this->get( $entityCfg->clazz, $entityCfg->getPkValues($obj) );

		/* not sure about this yet
		// if this entity hasn't been saved to the map
		if (!$data) return self::toArray($this->obj);
		*/

		$new = $entityCfg->toRow($data['obj']);

		$diff = array_diff_assoc($data['original'], $new);

		return array_keys($diff);
	}

	/**
	 * Updates an entity in the database including relationships.
	 * 
	 * It is safer to use the save function, it will take into account newness of the entity to determine insert vs update
	 *
	 * @see OutletMapper::save(&$obj)
	 * @param object $obj entity to update
	 */
	public function update(&$obj, OutletEntityConfig $entityCfg) {
		// this first since this references the key
		$this->saveManyToOne($obj, $entityCfg);

		if ($mod = $this->getModifiedFields($obj, $entityCfg)) {
			$con = $this->config->getConnection();
			$cls = $entityCfg->clazz;

			$q = "UPDATE {".$cls."} \n";
			$q .= "SET \n";

			$ups = array();
			foreach ($entityCfg->getProperties() as $key=>$f) {
				if (!in_array($key, $mod)) {
					// skip fields that were not modified
					continue;
				}

				if (@$f[2]['pk']) {
					// skip primary key
					continue;
				}

				$value = $entityCfg->getProp($obj, $key);
				if (is_null($value)) {
					$value = 'NULL';
				} else {
					$value = $con->quote( self::toSqlValue( $f[1], $value ) );
				}

				$ups[] = "  {".$cls.'.'.$key."} = $value";
			}
			$q .= implode(", \n", $ups);

			$q .= "\nWHERE ";

			$clause = array();
			foreach ($entityCfg->getProperties() as $key=>$pk) {
				
				if (!@$pk[2]['pk']) { 
					// if it's not a primary key, skip it
					continue;
				}

				$value = $con->quote( self::toSqlValue( $pk[1], $entityCfg->getProp($obj, $key) ) );
				$clause[] = "$pk[0] = $value";
			}
			$q .= implode(' AND ', $clause);

			$q = $this->processQuery($q);

			$con->exec($q);
		}

		// these last since they reference the key
		$this->saveOneToMany($obj, $entityCfg);
		$this->saveManyToMany($obj, $entityCfg);
	}

	/**
	 * Translates an entity into an associative array, applying OutletMapper::toSqlValue($conf, $v) on all values
	 * 
	 * @see OutletMapper::toSqlValue($conf, $v)
	 * @param object $entity entity to translate into an array
	 * @return array entity values
	 */
	public function toArray ($entity) {
		if (!$entity) throw new OutletException('You must pass an entity');

		$entityCfg = $this->config->getEntityForObject($entity);

		return $entityCfg->toRow($entity);
	}

	/**
	 * Translates a PHP value to a SQL Value
	 * @param array $conf configuration entry for the value
	 * @param mixed $v value to translate
	 * @return mixed translated value
	 */
	static function toSqlValue ($type, $v) {
		if (is_null($v)) {
			return NULL;
		}

		switch ($type) {
			case 'date': return $v->format('Y-m-d');
			case 'datetime': return $v->format('Y-m-d H:i:s');

			case 'int': return (int) $v;

			case 'float': return (float) $v;

			// Strings
			default: return $v;
		}
	}

	/**
	 * Translates an value to the expected value as defined in the configuration
	 * @param object $conf configuration entry for the value
	 * @param mixed $v value to translate
	 * @return mixed translated and casted value
	 */
	static function toPhpValue ($type, $v) {
		if (is_null($v)) {
			return NULL;
		}

		switch ($type) {
			case 'date':
			case 'datetime':
				if ($v instanceof DateTime) {
					return $v;
				}
				return new DateTime($v);

			case 'int': return (int) $v;

			case 'float': return (float) $v;

			// Strings
			default: return $v;
		}
	}
	
	/**
	 * Processes a subquery interpolating properties
	 * @param string $q query to process 
	 * @param string $class entity class
	 * @param string $alias alias
	 * @return string processed query
	 */
	function processSubQuery ($q, $class, $alias) {
		preg_match_all(self::IDENTIFIER_PATTERN, $q, $matches, PREG_SET_ORDER);
		
		foreach ($matches as $key=>$m) {
			// clear braces
			$str = substr($m[0], 1, -1);
			
			$propconf = $this->config->getEntity($class)->getProperty($str);

			$q = str_replace($m[0], $alias.'.'. $propconf[0], $q);
		}
		
		return $q;
	}
	
	/**
	 * Processes a query interpolating properties
	 * @param string $q query to process
	 * @return string processed query
	 */
	public function processQuery ( $q ) {
		preg_match_all(self::IDENTIFIER_PATTERN, $q, $matches, PREG_SET_ORDER);

		// check if it's an update statement
		$update = (stripos(trim($q), 'UPDATE')===0);

		// get the table names
		$aliased = array();
		foreach ($matches as $key=>$m) {
		// clear braces
			$str = substr($m[0], 1, -1);

			// if it's an aliased class
			if (strpos($str, ' ')!==false) {
				$tmp = explode(' ', $str);
				$aliased[$tmp[1]] = $tmp[0];

				$q = str_replace($m[0], $this->config->getEntity($tmp[0])->table.' '.$tmp[1], $q);

			// if it's a non-aliased class
			} elseif (strpos($str, '.')===false) {
			// if it's a non-aliased class
				$table = $this->config->getEntity($str)->table;
				$aliased[$table] = $str;
				$q = str_replace($m[0], $table, $q);
			}

		}

		// update references to the properties
		foreach ($matches as $key=>$m) {
		// clear braces
			$str = substr($m[0], 1, -1);

			// if it's a property
			if (strpos($str, '.')!==false) {
				list($en, $prop) = explode('.', $str);
				
				
				// if it's an alias
				if (isset($aliased[$en])) {
					$entity = $aliased[$en];
					$alias = $en;
				} else {
					$entity = $en;
					$alias = $this->config->getEntity($entity)->table;
				}
				
				$propconf = $this->config->getEntity($entity)->getProperty($prop);

				// if it's an update statement,
				// we must not include the table
				if ($update) {
					// skip if it's an sql field
					if (!isset($propconf[2]) || !isset($propconf[2]['sql'])) {
						$col = $propconf[0];
					}
				} else {
					// if it's an sql field
					if (isset($propconf[2]) && isset($propconf[2]['sql'])) {
						$col = '('. $this->processSubQuery($propconf[2]['sql'], $entity, $alias) .')';
					} else {
						$col = $alias.'.'.$propconf[0];
					}
				}

				$q = str_replace(
					$m[0],
					$col,
					$q
				);
			}
		}

		return $q;
	}

        private function hash ( $pks ) {
        	if (is_array($pks))
			return join(';', $pks);
		else
			return $pks;
        }

	/**
	 * Save object to the identity map
	 *
	 * @param string $clazz Class to save as
	 * @param array $pks Primary key values
	 * @param array $data Data to save
	 */
	public function set ( $clazz, array $pks, array $data ) {
//		$entityCfg = $this->config->getEntity($clazz);

		/** @todo Library should handle PK updates */
		// store on the map using the write type for the key (int, string)
//		$pks = $entityCfg->getPkValues($data['obj']);

		// just in case
		reset($pks);

		// if there's only one pk, use it instead of the array
		if (is_array($pks) && count($pks)==1) {
			$pks = current($pks);
		}

		// initialize map for this class
		if (!isset($this->map[$clazz])) {
			$this->map[$clazz] = array();
		}

		$this->map[$clazz][$this->hash($pks)] = $data;
	}

	/**
	 * Remove an entity from the cache
	 * 
	 * @param $clazz Class entity is stored as
	 * @param $pk primary key (not used, but required)
	 */
	public function clear ( $clazz, $pk ) {
		if (isset($this->map[$clazz])) {
			unset($this->map[$clazz]);
		} 
	}
	
	/**
	 * Clears the cache 
	 */
	public function clearCache () {
		$this->map = array();
	}

	/**
	 * Gets a class by primary key from the cache
	 * @param string $clazz Class to look up
	 * @param mixed $pk Primary key
	 * @return array array('obj'=>Entity, 'original'=>Original row used to populate entity)
	 */
	public function get ( $clazz, array $pk ) {
		/** @todo Library should handle PK updates */
		// if there's only one pk, use instead of the array
		if (is_array($pk) && count($pk)==1) { 
			$pk = array_shift($pk);
		}

		$hash = $this->hash($pk);
		if (isset($this->map[$clazz]) && isset($this->map[$clazz][$hash])) {
			return $this->map[$clazz][$hash];
		}
		return null;
	}

}

Return current item: Outlet