1: <?php
2: /**
3: * Rdo Mapper base class.
4: *
5: * @category Horde
6: * @package Rdo
7: */
8:
9: /**
10: * Rdo Mapper class. Controls mapping of entity obects (instances of
11: * Horde_Rdo_Base) from and to Horde_Db_Adapters.
12: *
13: * Public properties:
14: * $adapter - Horde_Db_Adapter that stores this Mapper's objects.
15: *
16: * $inflector - The Horde_Support_Inflector this mapper uses to singularize
17: * and pluralize PHP class, database table, and database field/key names.
18: *
19: * $table - The Horde_Db_Adapter_Base_TableDefinition object describing
20: * the main table of this entity.
21: *
22: * @category Horde
23: * @package Rdo
24: */
25: abstract class Horde_Rdo_Mapper implements Countable
26: {
27: /**
28: * If this is true and fields named created_at and updated_at are present,
29: * Rdo will automatically set creation and last updated timestamps.
30: * Timestamps are always GMT for portability.
31: *
32: * @var boolean
33: */
34: protected $_setTimestamps = true;
35:
36: /**
37: * What class should this Mapper create for objects? Defaults to the Mapper
38: * subclass' name minus "Mapper". So if the Rdo_Mapper subclass is
39: * UserMapper, it will default to trying to create User objects.
40: *
41: * @var string
42: */
43: protected $_classname;
44:
45: /**
46: * The definition of the database table (or view, etc.) that holds this
47: * Mapper's objects.
48: *
49: * @var Horde_Db_Adapter_Base_TableDefinition
50: */
51: protected $_tableDefinition;
52:
53: /**
54: * Fields that should only be read from the database when they are
55: * accessed.
56: *
57: * @var array
58: */
59: protected $_lazyFields = array();
60:
61: /**
62: * Relationships for this entity.
63: *
64: * @var array
65: */
66: protected $_relationships = array();
67:
68: /**
69: * Relationships that should only be read from the database when
70: * they are accessed.
71: *
72: * @var array
73: */
74: protected $_lazyRelationships = array();
75:
76: /**
77: * Default sorting rule to use for all queries made with this mapper. This
78: * is a SQL ORDER BY fragment (without 'ORDER BY').
79: *
80: * @var string
81: */
82: protected $_defaultSort;
83:
84: public function __construct(Horde_Db_Adapter $adapter)
85: {
86: $this->adapter = $adapter;
87: }
88:
89: /**
90: * Provide read-only, on-demand access to several properties. This
91: * method will only be called for properties that aren't already
92: * present; once a property is fetched once it is cached and
93: * returned directly on any subsequent access.
94: *
95: * These properties are available:
96: *
97: * adapter: The Horde_Db_Adapter this mapper is using to talk to
98: * the database.
99: *
100: * inflector: The Horde_Support_Inflector this Mapper uses to singularize
101: * and pluralize PHP class, database table, and database field/key names.
102: *
103: * table: The database table or view that this Mapper manages.
104: *
105: * tableDefinition: The Horde_Db_Adapter_Base_TableDefinition object describing
106: * the table or view this Mapper manages.
107: *
108: * fields: Array of all field names that are loaded up front
109: * (eager loading) from the table.
110: *
111: * lazyFields: Array of fields that are only loaded when accessed.
112: *
113: * relationships: Array of relationships to other Mappers.
114: *
115: * lazyRelationships: Array of relationships to other Mappers which
116: * are only loaded when accessed.
117: *
118: * @param string $key Property name to fetch
119: *
120: * @return mixed Value of $key
121: */
122: public function __get($key)
123: {
124: switch ($key) {
125: case 'inflector':
126: $this->inflector = new Horde_Support_Inflector();
127: return $this->inflector;
128:
129: case 'primaryKey':
130: $this->primaryKey = (string)$this->tableDefinition->getPrimaryKey();
131: return $this->primaryKey;
132:
133: case 'table':
134: $this->table = !empty($this->_table) ? $this->_table : $this->mapperToTable();
135: return $this->table;
136:
137: case 'tableDefinition':
138: $this->tableDefinition = $this->adapter->table($this->table);
139: return $this->tableDefinition;
140:
141: case 'fields':
142: $this->fields = array_diff($this->tableDefinition->getColumnNames(), $this->_lazyFields);
143: return $this->fields;
144:
145: case 'lazyFields':
146: case 'relationships':
147: case 'lazyRelationships':
148: case 'defaultSort':
149: return $this->{'_' . $key};
150: }
151:
152: return null;
153: }
154:
155: /**
156: * Create an instance of $this->_classname from a set of data.
157: *
158: * @param array $fields Field names/default values for the new object.
159: *
160: * @see $_classname
161: *
162: * @return Horde_Rdo_Base An instance of $this->_classname with $fields
163: * as initial data.
164: */
165: public function map($fields = array())
166: {
167: // Guess a classname if one isn't explicitly set.
168: if (!$this->_classname) {
169: $this->_classname = $this->mapperToEntity();
170: if (!$this->_classname) {
171: throw new Horde_Rdo_Exception('Unable to find an entity class (extending Horde_Rdo_Base) for ' . get_class($this));
172: }
173: }
174:
175: $o = new $this->_classname();
176: $o->setMapper($this);
177:
178: $this->mapFields($o, $fields);
179:
180: if (is_callable(array($o, 'afterMap'))) {
181: $o->afterMap();
182: }
183:
184: return $o;
185: }
186:
187: /**
188: * Update an instance of $this->_classname from a set of data.
189: *
190: * @param Horde_Rdo_Base $object The object to update
191: * @param array $fields Field names/default values for the object
192: */
193: public function mapFields($object, $fields = array())
194: {
195: $relationships = array();
196: foreach ($fields as $fieldName => &$fieldValue) {
197: if (strpos($fieldName, '@') !== false) {
198: list($rel, $field) = explode('@', $fieldName, 2);
199: $relationships[$rel][$field] = $fieldValue;
200: unset($fields[$fieldName]);
201: }
202: if (isset($this->fields[$fieldName])) {
203: $fieldName = $this->fields[$fieldName];
204: }
205: $column = $this->tableDefinition->getColumn($fieldName);
206: if ($column) {
207: $fieldValue = $column->typeCast($fieldValue);
208: }
209: }
210:
211: $object->setFields($fields);
212:
213: if (count($relationships)) {
214: foreach ($this->relationships as $relationship => $rel) {
215: if (isset($rel['mapper'])) {
216: // @TODO - should be getting this instance from somewhere
217: // else external, and not passing the adapter along
218: // automatically.
219: $m = new $rel['mapper']($this->adapter);
220: } else {
221: $m = $this->tableToMapper($relationship);
222: if (is_null($m)) {
223: // @TODO Throw an exception?
224: continue;
225: }
226: }
227:
228: if (isset($relationships[$m->table])) {
229: $object->$relationship = $m->map($relationships[$m->table]);
230: }
231: }
232: }
233: }
234:
235: /**
236: * Transform a table name to a mapper class name.
237: *
238: * @param string $table The database table name to look up.
239: *
240: * @return Horde_Rdo_Mapper A new Mapper instance if it exists, else null.
241: */
242: public function tableToMapper($table)
243: {
244: if (class_exists(($class = ucwords($table) . 'Mapper'))) {
245: return new $class;
246: }
247: return null;
248: }
249:
250: /**
251: * Transform this mapper's class name to a database table name.
252: *
253: * @return string The database table name.
254: */
255: public function mapperToTable()
256: {
257: return $this->inflector->pluralize(strtolower(str_replace('Mapper', '', get_class($this))));
258: }
259:
260: /**
261: * Transform this mapper's class name to an entity class name.
262: *
263: * @return string A Horde_Rdo_Base concrete class name if the class exists, else null.
264: */
265: public function mapperToEntity()
266: {
267: $class = str_replace('Mapper', '', get_class($this));
268: if (class_exists($class)) {
269: return $class;
270: }
271: return null;
272: }
273:
274: /**
275: * Count objects that match $query.
276: *
277: * @param mixed $query The query to count matches of.
278: *
279: * @return integer All objects matching $query.
280: */
281: public function count($query = null)
282: {
283: $query = Horde_Rdo_Query::create($query, $this);
284: $query->setFields('COUNT(*)')
285: ->clearSort();
286: list($sql, $bindParams) = $query->getQuery();
287: return $this->adapter->selectValue($sql, $bindParams);
288: }
289:
290: /**
291: * Check if at least one object matches $query.
292: *
293: * @param mixed $query Either a primary key, an array of keys
294: * => values, or a Horde_Rdo_Query object.
295: *
296: * @return boolean True or false.
297: */
298: public function exists($query)
299: {
300: $query = Horde_Rdo_Query::create($query, $this);
301: $query->setFields(1)
302: ->clearSort();
303: list($sql, $bindParams) = $query->getQuery();
304: return (bool)$this->adapter->selectValue($sql, $bindParams);
305: }
306:
307: /**
308: * Create a new object in the backend with $fields as initial values.
309: *
310: * @param array $fields Array of field names => initial values.
311: *
312: * @return Horde_Rdo_Base The newly created object.
313: */
314: public function create($fields)
315: {
316: // If configured to record creation and update times, set them
317: // here. We set updated_at to the initial creation time so it's
318: // always set.
319: if ($this->_setTimestamps) {
320: $time = gmmktime();
321: $fields['created_at'] = $time;
322: $fields['updated_at'] = $time;
323: }
324:
325: // Filter out any extra fields.
326: $fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames()));
327:
328: if (!$fields) {
329: throw new Horde_Rdo_Exception('create() requires at least one field value.');
330: }
331:
332: $sql = 'INSERT INTO ' . $this->adapter->quoteTableName($this->table);
333: $keys = array();
334: $placeholders = array();
335: $bindParams = array();
336: foreach ($fields as $field => $value) {
337: $keys[] = $this->adapter->quoteColumnName($field);
338: $placeholders[] = '?';
339: $bindParams[] = $value;
340: }
341: $sql .= ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $placeholders) . ')';
342:
343: $id = $this->adapter->insert($sql, $bindParams);
344:
345: return $this->map(array_merge($fields, array($this->primaryKey => $id)));
346: }
347:
348: /**
349: * Updates a record in the backend. $object can be either a
350: * primary key or an Rdo object. If $object is an Rdo instance
351: * then $fields will be ignored as values will be pulled from the
352: * object.
353: *
354: * @param string|Rdo $object The Rdo instance or unique id to update.
355: * @param array $fields If passing a unique id, the array of field properties
356: * to set for $object.
357: *
358: * @return integer Number of objects updated.
359: */
360: public function update($object, $fields = null)
361: {
362: if ($object instanceof Horde_Rdo_Base) {
363: $key = $this->primaryKey;
364: $id = $object->$key;
365: $fields = iterator_to_array($object);
366:
367: if (!$id) {
368: // Object doesn't exist yet; create it instead.
369: $o = $this->create($fields);
370: $this->mapFields($object, iterator_to_array($o));
371: return 1;
372: }
373: } else {
374: $id = $object;
375: }
376:
377: // If configured to record update time, set it here.
378: if ($this->_setTimestamps) {
379: $fields['updated_at'] = gmmktime();
380: }
381:
382: // Filter out any extra fields.
383: $fields = array_intersect_key($fields, array_flip($this->tableDefinition->getColumnNames()));
384:
385: if (!$fields) {
386: // Nothing to change.
387: return 0;
388: }
389:
390: $sql = 'UPDATE ' . $this->adapter->quoteTableName($this->table) . ' SET';
391: $bindParams = array();
392: foreach ($fields as $field => $value) {
393: $sql .= ' ' . $this->adapter->quoteColumnName($field) . ' = ?,';
394: $bindParams[] = $value;
395: }
396: $sql = substr($sql, 0, -1) . ' WHERE ' . $this->primaryKey . ' = ?';
397: $bindParams[] = $id;
398:
399: return $this->adapter->update($sql, $bindParams);
400: }
401:
402: /**
403: * Deletes a record from the backend. $object can be either a
404: * primary key, an Rdo_Query object, or an Rdo object.
405: *
406: * @param string|Horde_Rdo_Base|Horde_Rdo_Query $object The Rdo object,
407: * Horde_Rdo_Query, or unique id to delete.
408: *
409: * @return integer Number of objects deleted.
410: */
411: public function delete($object)
412: {
413: if ($object instanceof Horde_Rdo_Base) {
414: $key = $this->primaryKey;
415: $id = $object->$key;
416: $query = array($key => $id);
417: } elseif ($object instanceof Horde_Rdo_Query) {
418: $query = $object;
419: } else {
420: $key = $this->primaryKey;
421: $query = array($key => $object);
422: }
423:
424: $query = Horde_Rdo_Query::create($query, $this);
425:
426: $clauses = array();
427: $bindParams = array();
428: foreach ($query->tests as $test) {
429: $clauses[] = $this->adapter->quoteColumnName($test['field']) . ' ' . $test['test'] . ' ?';
430: $bindParams[] = $test['value'];
431: }
432: if (!$clauses) {
433: throw new Horde_Rdo_Exception('Refusing to delete the entire table.');
434: }
435:
436: $sql = 'DELETE FROM ' . $this->adapter->quoteTableName($this->table) .
437: ' WHERE ' . implode(' ' . $query->conjunction . ' ', $clauses);
438:
439: return $this->adapter->delete($sql, $bindParams);
440: }
441:
442: /**
443: * find() can be called in several ways.
444: *
445: * Primary key mode: pass find() a numerically indexed array of primary
446: * keys, and it will return a list of the objects that correspond to those
447: * keys.
448: *
449: * If you pass find() no arguments, all objects of this type will be
450: * returned.
451: *
452: * If you pass find() an associative array, it will be turned into a
453: * Horde_Rdo_Query object.
454: *
455: * If you pass find() a Horde_Rdo_Query, it will return a list of all
456: * objects matching that query.
457: */
458: public function find($arg = null)
459: {
460: if (is_null($arg)) {
461: $query = null;
462: } elseif (is_array($arg)) {
463: if (!count($arg)) {
464: throw new Horde_Rdo_Exception('No criteria found');
465: }
466:
467: if (is_numeric(key($arg))) {
468: // Numerically indexed arrays are assumed to be an array of
469: // primary keys.
470: $query = new Horde_Rdo_Query();
471: $query->combineWith('OR');
472: foreach ($argv[0] as $id) {
473: $query->addTest($this->primaryKey, '=', $id);
474: }
475: } else {
476: $query = $arg;
477: }
478: } else {
479: $query = $arg;
480: }
481:
482: // Build a full Query object.
483: $query = Horde_Rdo_Query::create($query, $this);
484: return new Horde_Rdo_List($query);
485: }
486:
487: /**
488: * findOne can be called in several ways.
489: *
490: * Primary key mode: pass find() a single primary key, and it will return a
491: * single object matching that primary key.
492: *
493: * If you pass findOne() no arguments, the first object of this type will be
494: * returned.
495: *
496: * If you pass findOne() an associative array, it will be turned into a
497: * Horde_Rdo_Query object.
498: *
499: * If you pass findOne() a Horde_Rdo_Query, it will return the first object
500: * matching that query.
501: */
502: public function findOne($arg = null)
503: {
504: if (is_null($arg)) {
505: $query = null;
506: } elseif (is_scalar($arg)) {
507: $query = array($this->primaryKey => $arg);
508: } else {
509: $query = $arg;
510: }
511:
512: // Build a full Query object, and limit it to one result.
513: $query = Horde_Rdo_Query::create($query, $this);
514: $query->limit(1);
515:
516: $list = new Horde_Rdo_List($query);
517: return $list->current();
518: }
519:
520: /**
521: * Set a default sort rule for all queries done with this Mapper.
522: *
523: * @param string $sort SQL sort fragment, such as 'updated DESC'
524: */
525: public function sortBy($sort)
526: {
527: $this->_defaultSort = $sort;
528: return $this;
529: }
530: }
531: