690 lines
22 KiB
PHP
690 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* @category Horde
|
|
* @package Rdo
|
|
*/
|
|
|
|
/**
|
|
* Rdo Mapper class.
|
|
*
|
|
* Controls mapping of entity obects (instances of Horde_Rdo_Base) from and to
|
|
* Horde_Db_Adapters.
|
|
*
|
|
* Public properties:
|
|
* $adapter - Horde_Db_Adapter that stores this Mapper's objects.
|
|
*
|
|
* $inflector - The Horde_Support_Inflector this mapper uses to singularize
|
|
* and pluralize PHP class, database table, and database field/key names.
|
|
*
|
|
* $table - The Horde_Db_Adapter_Base_TableDefinition object describing
|
|
* the main table of this entity.
|
|
*
|
|
* @category Horde
|
|
* @package Rdo
|
|
*/
|
|
abstract class Horde_Rdo_Mapper implements Countable
|
|
{
|
|
/**
|
|
* If this is true and fields named created_at and updated_at are present,
|
|
* Rdo will automatically set creation and last updated timestamps.
|
|
* Timestamps are always GMT for portability.
|
|
*
|
|
* @var boolean
|
|
*/
|
|
protected $_setTimestamps = true;
|
|
|
|
/**
|
|
* What class should this Mapper create for objects? Defaults to the Mapper
|
|
* subclass' name minus "Mapper". So if the Rdo_Mapper subclass is
|
|
* UserMapper, it will default to trying to create User objects.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $_classname;
|
|
|
|
/**
|
|
* The definition of the database table (or view, etc.) that holds this
|
|
* Mapper's objects.
|
|
*
|
|
* @var Horde_Db_Adapter_Base_TableDefinition
|
|
*/
|
|
protected $_tableDefinition;
|
|
|
|
/**
|
|
* Fields that should only be read from the database when they are
|
|
* accessed.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $_lazyFields = array();
|
|
|
|
/**
|
|
* Relationships for this entity.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $_relationships = array();
|
|
|
|
/**
|
|
* Relationships that should only be read from the database when
|
|
* they are accessed.
|
|
*
|
|
* @var array
|
|
*/
|
|
protected $_lazyRelationships = array();
|
|
|
|
/**
|
|
* Default sorting rule to use for all queries made with this mapper. This
|
|
* is a SQL ORDER BY fragment (without 'ORDER BY').
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $_defaultSort;
|
|
|
|
/**
|
|
* The caching factory, if used
|
|
*
|
|
* @var Horde_Rdo_Factory
|
|
*/
|
|
protected $_factory = null;
|
|
|
|
public function __construct(Horde_Db_Adapter $adapter)
|
|
{
|
|
$this->adapter = $adapter;
|
|
}
|
|
|
|
/**
|
|
* Attach a Horde_Rdo_Factory to the mapper.
|
|
* If called without arguments, detaches the mapper from factory
|
|
*
|
|
* @param Horde_Rdo_Factory $factory A Factory instance or null
|
|
* @return Horde_Rdo_Mapper this mapper
|
|
*/
|
|
public function setFactory(Horde_Rdo_Factory $factory = null)
|
|
{
|
|
$this->_factory = $factory;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Provide read-only, on-demand access to several properties. This
|
|
* method will only be called for properties that aren't already
|
|
* present; once a property is fetched once it is cached and
|
|
* returned directly on any subsequent access.
|
|
*
|
|
* These properties are available:
|
|
*
|
|
* adapter: The Horde_Db_Adapter this mapper is using to talk to
|
|
* the database.
|
|
*
|
|
* factory: The Horde_Rdo_Factory instance, if present
|
|
*
|
|
* inflector: The Horde_Support_Inflector this Mapper uses to singularize
|
|
* and pluralize PHP class, database table, and database field/key names.
|
|
*
|
|
* table: The database table or view that this Mapper manages.
|
|
*
|
|
* tableDefinition: The Horde_Db_Adapter_Base_TableDefinition object describing
|
|
* the table or view this Mapper manages.
|
|
*
|
|
* fields: Array of all field names that are loaded up front
|
|
* (eager loading) from the table.
|
|
*
|
|
* lazyFields: Array of fields that are only loaded when accessed.
|
|
*
|
|
* relationships: Array of relationships to other Mappers.
|
|
*
|
|
* lazyRelationships: Array of relationships to other Mappers which
|
|
* are only loaded when accessed.
|
|
*
|
|
* @param string $key Property name to fetch
|
|
*
|
|
* @return mixed Value of $key
|
|
*/
|
|
public function __get($key)
|
|
{
|
|
switch ($key) {
|
|
case 'inflector':
|
|
$this->inflector = new Horde_Support_Inflector();
|
|
return $this->inflector;
|
|
|
|
case 'primaryKey':
|
|
$this->primaryKey = (string)$this->tableDefinition->getPrimaryKey();
|
|
return $this->primaryKey;
|
|
|
|
case 'table':
|
|
$this->table = !empty($this->_table) ? $this->_table : $this->mapperToTable();
|
|
return $this->table;
|
|
|
|
case 'tableDefinition':
|
|
$this->tableDefinition = $this->adapter->table($this->table);
|
|
return $this->tableDefinition;
|
|
|
|
case 'fields':
|
|
$this->fields = array_diff($this->tableDefinition->getColumnNames(), $this->_lazyFields);
|
|
return $this->fields;
|
|
|
|
case 'lazyFields':
|
|
case 'relationships':
|
|
case 'lazyRelationships':
|
|
case 'factory':
|
|
case 'defaultSort':
|
|
return $this->{'_' . $key};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create an instance of $this->_classname from a set of data.
|
|
*
|
|
* @param array $fields Field names/default values for the new object.
|
|
*
|
|
* @see $_classname
|
|
*
|
|
* @return Horde_Rdo_Base An instance of $this->_classname with $fields
|
|
* as initial data.
|
|
*/
|
|
public function map($fields = array())
|
|
{
|
|
// Guess a classname if one isn't explicitly set.
|
|
if (!$this->_classname) {
|
|
$this->_classname = $this->mapperToEntity();
|
|
if (!$this->_classname) {
|
|
throw new Horde_Rdo_Exception('Unable to find an entity class (extending Horde_Rdo_Base) for ' . get_class($this));
|
|
}
|
|
}
|
|
|
|
$o = new $this->_classname();
|
|
$o->setMapper($this);
|
|
|
|
$this->mapFields($o, $fields);
|
|
|
|
if (is_callable(array($o, 'afterMap'))) {
|
|
$o->afterMap();
|
|
}
|
|
|
|
return $o;
|
|
}
|
|
|
|
/**
|
|
* Update an instance of $this->_classname from a set of data.
|
|
*
|
|
* @param Horde_Rdo_Base $object The object to update
|
|
* @param array $fields Field names/default values for the object
|
|
*/
|
|
public function mapFields($object, $fields = array())
|
|
{
|
|
$relationships = array();
|
|
foreach ($fields as $fieldName => &$fieldValue) {
|
|
if (strpos($fieldName, '@') !== false) {
|
|
list($rel, $field) = explode('@', $fieldName, 2);
|
|
$relationships[$rel][$field] = $fieldValue;
|
|
unset($fields[$fieldName]);
|
|
}
|
|
if (isset($this->fields[$fieldName])) {
|
|
$fieldName = $this->fields[$fieldName];
|
|
}
|
|
$column = $this->tableDefinition->getColumn($fieldName);
|
|
if ($column) {
|
|
$fieldValue = $column->typeCast($fieldValue);
|
|
}
|
|
}
|
|
|
|
$object->setFields($fields);
|
|
|
|
if (count($relationships)) {
|
|
foreach ($this->relationships as $relationship => $rel) {
|
|
if (isset($rel['mapper'])) {
|
|
if ($this->_factory) {
|
|
$m = $this->_factory->create($rel['mapper']);
|
|
}
|
|
// @TODO - should be getting this instance from somewhere
|
|
// else external, and not passing the adapter along
|
|
// automatically.
|
|
else {
|
|
$m = new $rel['mapper']($this->adapter);
|
|
}
|
|
} else {
|
|
$m = $this->tableToMapper($relationship);
|
|
if (is_null($m)) {
|
|
// @TODO Throw an exception?
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Don't check the table only. If there was a match for LEFT
|
|
// JOINs, there may be a "empty" result
|
|
if (isset($relationships[$m->table], $relationships[$m->table][$m->primaryKey])) {
|
|
$object->$relationship = $m->map($relationships[$m->table]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transform a table name to a mapper class name.
|
|
*
|
|
* @param string $table The database table name to look up.
|
|
*
|
|
* @return Horde_Rdo_Mapper A new Mapper instance if it exists, else null.
|
|
*/
|
|
public function tableToMapper($table)
|
|
{
|
|
if (class_exists(($class = Horde_String::ucwords($table) . 'Mapper'))) {
|
|
return new $class;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Transform this mapper's class name to a database table name.
|
|
*
|
|
* @return string The database table name.
|
|
*/
|
|
public function mapperToTable()
|
|
{
|
|
return $this->inflector->pluralize(Horde_String::lower(str_replace('Mapper', '', get_class($this))));
|
|
}
|
|
|
|
/**
|
|
* Transform this mapper's class name to an entity class name.
|
|
*
|
|
* @return string A Horde_Rdo_Base concrete class name if the class exists, else null.
|
|
*/
|
|
public function mapperToEntity()
|
|
{
|
|
$class = str_replace('Mapper', '', get_class($this));
|
|
if (class_exists($class)) {
|
|
return $class;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Count objects that match $query.
|
|
*
|
|
* @param mixed $query The query to count matches of.
|
|
*
|
|
* @return integer All objects matching $query.
|
|
*/
|
|
public function count($query = null)
|
|
{
|
|
$query = Horde_Rdo_Query::create($query, $this);
|
|
$query->setFields('COUNT(*)')
|
|
->clearSort();
|
|
list($sql, $bindParams) = $query->getQuery();
|
|
return $this->adapter->selectValue($sql, $bindParams);
|
|
}
|
|
|
|
/**
|
|
* Check if at least one object matches $query.
|
|
*
|
|
* @param mixed $query Either a primary key, an array of keys
|
|
* => values, or a Horde_Rdo_Query object.
|
|
*
|
|
* @return boolean True or false.
|
|
*/
|
|
public function exists($query)
|
|
{
|
|
$query = Horde_Rdo_Query::create($query, $this);
|
|
$query->setFields(1)
|
|
->clearSort();
|
|
list($sql, $bindParams) = $query->getQuery();
|
|
return (bool)$this->adapter->selectValue($sql, $bindParams);
|
|
}
|
|
|
|
/**
|
|
* Create a new object in the backend with $fields as initial values.
|
|
*
|
|
* @param array $fields Array of field names => initial values.
|
|
*
|
|
* @return Horde_Rdo_Base The newly created object.
|
|
*/
|
|
public function create($fields)
|
|
{
|
|
// If configured to record creation and update times, set them
|
|
// here. We set updated_at to the initial creation time so it's
|
|
// always set.
|
|
if ($this->_setTimestamps) {
|
|
$time = time();
|
|
$fields['created_at'] = $time;
|
|
$fields['updated_at'] = $time;
|
|
}
|
|
|
|
// Filter out any extra fields.
|
|
$fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames()));
|
|
|
|
if (!$fields) {
|
|
throw new Horde_Rdo_Exception('create() requires at least one field value.');
|
|
}
|
|
|
|
$sql = 'INSERT INTO ' . $this->adapter->quoteTableName($this->table);
|
|
$keys = array();
|
|
$placeholders = array();
|
|
$bindParams = array();
|
|
foreach ($fields as $field => $value) {
|
|
$keys[] = $this->adapter->quoteColumnName($field);
|
|
$placeholders[] = '?';
|
|
$bindParams[] = $value;
|
|
}
|
|
$sql .= ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $placeholders) . ')';
|
|
|
|
$id = $this->adapter->insert($sql, $bindParams);
|
|
|
|
return $this->map(array_merge($fields, array($this->primaryKey => $id)));
|
|
}
|
|
|
|
/**
|
|
* Updates a record in the backend. $object can be either a
|
|
* primary key or an Rdo object. If $object is an Rdo instance
|
|
* then $fields will be ignored as values will be pulled from the
|
|
* object.
|
|
*
|
|
* @param string|Rdo $object The Rdo instance or unique id to update.
|
|
* @param array $fields If passing a unique id, the array of field properties
|
|
* to set for $object.
|
|
*
|
|
* @return integer Number of objects updated.
|
|
*/
|
|
public function update($object, $fields = null)
|
|
{
|
|
if ($object instanceof Horde_Rdo_Base) {
|
|
$key = $this->primaryKey;
|
|
$id = $object->$key;
|
|
$fields = iterator_to_array($object);
|
|
|
|
if (!$id) {
|
|
// Object doesn't exist yet; create it instead.
|
|
$o = $this->create($fields);
|
|
$this->mapFields($object, iterator_to_array($o));
|
|
return 1;
|
|
}
|
|
} else {
|
|
$id = $object;
|
|
}
|
|
|
|
// If configured to record update time, set it here.
|
|
if ($this->_setTimestamps) {
|
|
$fields['updated_at'] = time();
|
|
}
|
|
|
|
// Filter out any extra fields.
|
|
$fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames()));
|
|
|
|
if (!$fields) {
|
|
// Nothing to change.
|
|
return 0;
|
|
}
|
|
|
|
$sql = 'UPDATE ' . $this->adapter->quoteTableName($this->table) . ' SET';
|
|
$bindParams = array();
|
|
foreach ($fields as $field => $value) {
|
|
$sql .= ' ' . $this->adapter->quoteColumnName($field) . ' = ?,';
|
|
$bindParams[] = $value;
|
|
}
|
|
$sql = substr($sql, 0, -1) . ' WHERE ' . $this->primaryKey . ' = ?';
|
|
$bindParams[] = $id;
|
|
|
|
return $this->adapter->update($sql, $bindParams);
|
|
}
|
|
|
|
/**
|
|
* Deletes a record from the backend. $object can be either a
|
|
* primary key, an Rdo_Query object, or an Rdo object.
|
|
*
|
|
* @param string|Horde_Rdo_Base|Horde_Rdo_Query $object The Rdo object,
|
|
* Horde_Rdo_Query, or unique id to delete.
|
|
*
|
|
* @return integer Number of objects deleted.
|
|
*/
|
|
public function delete($object)
|
|
{
|
|
if ($object instanceof Horde_Rdo_Base) {
|
|
$key = $this->primaryKey;
|
|
$id = $object->$key;
|
|
$query = array($key => $id);
|
|
} elseif ($object instanceof Horde_Rdo_Query) {
|
|
$query = $object;
|
|
} else {
|
|
$key = $this->primaryKey;
|
|
$query = array($key => $object);
|
|
}
|
|
|
|
$query = Horde_Rdo_Query::create($query, $this);
|
|
|
|
$clauses = array();
|
|
$bindParams = array();
|
|
foreach ($query->tests as $test) {
|
|
$clauses[] = $this->adapter->quoteColumnName($test['field']) . ' ' . $test['test'] . ' ?';
|
|
$bindParams[] = $test['value'];
|
|
}
|
|
if (!$clauses) {
|
|
throw new Horde_Rdo_Exception('Refusing to delete the entire table.');
|
|
}
|
|
|
|
$sql = 'DELETE FROM ' . $this->adapter->quoteTableName($this->table) .
|
|
' WHERE ' . implode(' ' . $query->conjunction . ' ', $clauses);
|
|
|
|
return $this->adapter->delete($sql, $bindParams);
|
|
}
|
|
|
|
/**
|
|
* find() can be called in several ways.
|
|
*
|
|
* Primary key mode: pass find() a numerically indexed array of primary
|
|
* keys, and it will return a list of the objects that correspond to those
|
|
* keys.
|
|
*
|
|
* If you pass find() no arguments, all objects of this type will be
|
|
* returned.
|
|
*
|
|
* If you pass find() an associative array, it will be turned into a
|
|
* Horde_Rdo_Query object.
|
|
*
|
|
* If you pass find() a Horde_Rdo_Query, it will return a list of all
|
|
* objects matching that query.
|
|
*/
|
|
public function find($arg = null)
|
|
{
|
|
if (is_null($arg)) {
|
|
$query = null;
|
|
} elseif (is_array($arg)) {
|
|
if (!count($arg)) {
|
|
throw new Horde_Rdo_Exception('No criteria found');
|
|
}
|
|
|
|
if (is_numeric(key($arg))) {
|
|
// Numerically indexed arrays are assumed to be an array of
|
|
// primary keys.
|
|
$query = new Horde_Rdo_Query();
|
|
$query->combineWith('OR');
|
|
foreach ($argv[0] as $id) {
|
|
$query->addTest($this->primaryKey, '=', $id);
|
|
}
|
|
} else {
|
|
$query = $arg;
|
|
}
|
|
} else {
|
|
$query = $arg;
|
|
}
|
|
|
|
// Build a full Query object.
|
|
$query = Horde_Rdo_Query::create($query, $this);
|
|
return new Horde_Rdo_List($query);
|
|
}
|
|
|
|
/**
|
|
* Adds a relation.
|
|
*
|
|
* - For one-to-one relations, simply updates the relation field.
|
|
* - For one-to-many relations, updates the related object's relation field.
|
|
* - For many-to-many relations, adds an entry in the "through" table.
|
|
* - Performs a no-op if the peer is already related.
|
|
*
|
|
* @param string $relationship The relationship key in the mapper.
|
|
* @param Horde_Rdo_Base $ours The object from this mapper to add the
|
|
* relation.
|
|
* @param Horde_Rdo_Base $theirs The other object from any mapper to add
|
|
* the relation.
|
|
*
|
|
* @throws Horde_Rdo_Exception
|
|
*/
|
|
public function addRelation($relationship, Horde_Rdo_Base $ours,
|
|
Horde_Rdo_Base $theirs)
|
|
{
|
|
if ($ours->hasRelation($relationship, $theirs)) {
|
|
return;
|
|
}
|
|
|
|
$ourKey = $this->primaryKey;
|
|
$theirKey = $theirs->mapper->primaryKey;
|
|
|
|
if (isset($this->relationships[$relationship])) {
|
|
$rel = $this->relationships[$relationship];
|
|
} elseif (isset($this->lazyRelationships[$relationship])) {
|
|
$rel = $this->lazyRelationships[$relationship];
|
|
} else {
|
|
throw new Horde_Rdo_Exception('The requested relation is not defined in the mapper');
|
|
}
|
|
|
|
switch ($rel['type']) {
|
|
case Horde_Rdo::ONE_TO_ONE:
|
|
case Horde_Rdo::MANY_TO_ONE:
|
|
$ours->{$rel['foreignKey']} = $theirs->$theirKey;
|
|
$ours->save();
|
|
break;
|
|
|
|
case Horde_Rdo::ONE_TO_MANY:
|
|
$theirs->{$rel['foreignKey']} = $ours->$ourKey;
|
|
$theirs->save();
|
|
break;
|
|
|
|
case Horde_Rdo::MANY_TO_MANY:
|
|
$sql = sprintf('INSERT INTO %s (%s, %s) VALUES (?, ?)',
|
|
$this->adapter->quoteTableName($rel['through']),
|
|
$this->adapter->quoteColumnName($ourKey),
|
|
$this->adapter->quoteColumnName($theirKey));
|
|
try {
|
|
$this->adapter->insert($sql, array($ours->$ourKey, $theirs->$theirKey));
|
|
} catch (Horde_Db_Exception $e) {
|
|
throw new Horde_Rdo_Exception($e);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a relation to one of the relationships defined in the mapper.
|
|
*
|
|
* - For one-to-one and one-to-many relations, simply sets the relation
|
|
* field to 0.
|
|
* - For many-to-many, either deletes all relations to this object or just
|
|
* the relation to a given peer object.
|
|
* - Performs a no-op if the peer is already unrelated.
|
|
*
|
|
* This is a proxy to the mapper's removeRelation method.
|
|
*
|
|
* @param string $relationship The relationship key in the mapper.
|
|
* @param Horde_Rdo_Base $ours The object from this mapper.
|
|
* @param Horde_Rdo_Base $theirs The object to remove from the relation.
|
|
* @return integer the number of affected relations
|
|
*
|
|
* @throws Horde_Rdo_Exception
|
|
*/
|
|
public function removeRelation($relationship, Horde_Rdo_Base $ours,
|
|
Horde_Rdo_Base $theirs = null)
|
|
{
|
|
if (!$ours->hasRelation($relationship, $theirs)) {
|
|
return;
|
|
}
|
|
|
|
$ourKey = $this->primaryKey;
|
|
|
|
if (isset($this->relationships[$relationship])) {
|
|
$rel = $this->relationships[$relationship];
|
|
} elseif (isset($this->lazyRelationships[$relationship])) {
|
|
$rel = $this->lazyRelationships[$relationship];
|
|
} else {
|
|
throw new Horde_Rdo_Exception('The requested relation is not defined in the mapper');
|
|
}
|
|
|
|
switch ($rel['type']) {
|
|
case Horde_Rdo::ONE_TO_ONE:
|
|
case Horde_Rdo::MANY_TO_ONE:
|
|
$ours->{$rel['foreignKey']} = null;
|
|
$ours->save();
|
|
return 1;
|
|
break;
|
|
|
|
case Horde_Rdo::ONE_TO_MANY:
|
|
$theirs->{$rel['foreignKey']} = null;
|
|
$theirs->save();
|
|
return 1;
|
|
break;
|
|
|
|
case Horde_Rdo::MANY_TO_MANY:
|
|
$sql = sprintf('DELETE FROM %s WHERE %s = ? ',
|
|
$this->adapter->quoteTableName($rel['through']),
|
|
$this->adapter->quoteColumnName($ourKey));
|
|
$values = array($ours->$ourKey);
|
|
if (!empty($theirs)) {
|
|
$theirKey = $theirs->mapper->primaryKey;
|
|
$sql .= sprintf(' AND %s = ?',
|
|
$this->adapter->quoteColumnName($theirKey));
|
|
$values[] = $theirs->$theirKey;
|
|
}
|
|
try {
|
|
return $this->adapter->delete($sql, $values);
|
|
} catch (Horde_Db_Exception $e) {
|
|
throw new Horde_Rdo_Exception($e);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* findOne can be called in several ways.
|
|
*
|
|
* Primary key mode: pass find() a single primary key, and it will return a
|
|
* single object matching that primary key.
|
|
*
|
|
* If you pass findOne() no arguments, the first object of this type will be
|
|
* returned.
|
|
*
|
|
* If you pass findOne() an associative array, it will be turned into a
|
|
* Horde_Rdo_Query object.
|
|
*
|
|
* If you pass findOne() a Horde_Rdo_Query, it will return the first object
|
|
* matching that query.
|
|
*/
|
|
public function findOne($arg = null)
|
|
{
|
|
if (is_null($arg)) {
|
|
$query = null;
|
|
} elseif (is_scalar($arg)) {
|
|
$query = array($this->primaryKey => $arg);
|
|
} else {
|
|
$query = $arg;
|
|
}
|
|
|
|
// Build a full Query object, and limit it to one result.
|
|
$query = Horde_Rdo_Query::create($query, $this);
|
|
$query->limit(1);
|
|
|
|
$list = new Horde_Rdo_List($query);
|
|
return $list->current();
|
|
}
|
|
|
|
/**
|
|
* Set a default sort rule for all queries done with this Mapper.
|
|
*
|
|
* @param string $sort SQL sort fragment, such as 'updated DESC'
|
|
*/
|
|
public function sortBy($sort)
|
|
{
|
|
$this->_defaultSort = $sort;
|
|
return $this;
|
|
}
|
|
}
|