import rdo to the hatchery while it's ported to Horde_Db
authorChuck Hagenbuch <chuck@horde.org>
Sat, 13 Dec 2008 03:19:24 +0000 (22:19 -0500)
committerChuck Hagenbuch <chuck@horde.org>
Sat, 13 Dec 2008 03:19:24 +0000 (22:19 -0500)
framework/Rdo/lib/Horde/Rdo.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Base.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Iterator.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/List.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Mapper.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Model.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Query.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Query/Builder.php [new file with mode: 0644]
framework/Rdo/lib/Horde/Rdo/Query/Literal.php [new file with mode: 0644]

diff --git a/framework/Rdo/lib/Horde/Rdo.php b/framework/Rdo/lib/Horde/Rdo.php
new file mode 100644 (file)
index 0000000..55052ad
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Copyright 2006-2008 The Horde Project (http://www.horde.org/)
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Db
+ */
+
+/**
+ * Horde_Db namespace - holds constants and global Db functions.
+ *
+ * @author   Chuck Hagenbuch <chuck@horde.org>
+ * @license  http://opensource.org/licenses/bsd-license.php BSD
+ * @category Horde
+ * @package  Horde_Db
+ */
+class Horde_Rdo
+{
+    /**
+     * One-to-one relationships.
+     */
+    const ONE_TO_ONE = 1;
+
+    /**
+     * One-to-many relationships (this object has many children).
+     */
+    const ONE_TO_MANY = 2;
+
+    /**
+     * Many-to-one relationships (this object is one of many children
+     * of a single parent).
+     */
+    const MANY_TO_ONE = 3;
+
+    /**
+     * Many-to-many relationships (this object relates to many
+     * objects, each of which relate to many objects of this type).
+     */
+    const MANY_TO_MANY = 4;
+
+    /**
+     * Global inflector object.
+     *
+     * @var Horde_Support_Inflector
+     */
+    protected static $_inflector;
+
+    /**
+     * Get the global inflector object.
+     *
+     * @return Horde_Support_Inflector
+     */
+    public static function getInflector()
+    {
+        if (!self::$_inflector) {
+            self::$_inflector = new Horde_Support_Inflector;
+        }
+        return self::$_inflector;
+    }
+
+    /**
+     * Set a custom global inflector.
+     *
+     * @param Horde_Support_Inflector $inflector
+     */
+    public static function setInflector($inflector)
+    {
+        self::$_inflector = $inflector;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Base.php b/framework/Rdo/lib/Horde/Rdo/Base.php
new file mode 100644 (file)
index 0000000..305f4c3
--- /dev/null
@@ -0,0 +1,292 @@
+<?php
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * Horde_Rdo_Base abstract class (Rampage Data Objects). Entity
+ * classes extend this baseline.
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+abstract class Horde_Rdo_Base implements IteratorAggregate {
+
+    /**
+     * The Horde_Rdo_Mapper instance associated with this Rdo object. The
+     * Mapper takes care of all backend access.
+     *
+     * @see Horde_Rdo_Mapper
+     * @var Horde_Rdo_Mapper
+     */
+    protected $_mapper;
+
+    /**
+     * This object's fields.
+     *
+     * @var array
+     */
+    protected $_fields = array();
+
+    /**
+     * Constructor. Can be called directly by a programmer, or is
+     * called in Horde_Rdo_Mapper::map(). Takes an associative array
+     * of initial object values.
+     *
+     * @param array $fields Initial values for the new object.
+     *
+     * @see Horde_Rdo_Mapper::map()
+     */
+    public function __construct($fields = array())
+    {
+        $this->_fields = $fields;
+    }
+
+    /**
+     * When Rdo objects are cloned, unset the unique id that
+     * identifies them so that they can be modified and saved to the
+     * backend as new objects. If you don't really want a new object,
+     * don't clone.
+     */
+    public function __clone()
+    {
+        // @TODO Support composite primary keys
+        unset($this->{$this->getMapper()->model->key});
+
+        // @TODO What about associated objects?
+    }
+
+    /**
+     * Fetch fields that haven't yet been loaded. Lazy-loaded fields
+     * and lazy-loaded relationships are handled this way. Once a
+     * field is retrieved, it is cached in the $_fields array so it
+     * doesn't need to be fetched again.
+     *
+     * @param string $field The name of the field to access.
+     *
+     * @return mixed The value of $field or null.
+     */
+    public function __get($field)
+    {
+        // Honor any explicit getters.
+        $fieldMethod = 'get' . ucfirst($field);
+        // If an Rdo_Base subclass has a __call() method, is_callable
+        // returns true on every method name, so use method_exists
+        // instead.
+        if (method_exists($this, $fieldMethod)) {
+            return call_user_func(array($this, $fieldMethod));
+        }
+
+        if (isset($this->_fields[$field])) {
+            return $this->_fields[$field];
+        }
+
+        $mapper = $this->getMapper();
+
+        // Look for lazy fields first, then relationships.
+        if (in_array($field, $mapper->lazyFields)) {
+            // @TODO Support composite primary keys
+            $query = new Horde_Rdo_Query($mapper);
+            $query->setFields($field)
+                  ->addTest($mapper->model->key, '=', $this->{$mapper->model->key});
+            $this->_fields[$field] = $mapper->adapter->queryOne($query);
+            return $this->_fields[$field];
+        } elseif (isset($mapper->lazyRelationships[$field])) {
+            $rel = $mapper->lazyRelationships[$field];
+        } else {
+            return null;
+        }
+
+        // Try to find the Mapper class for the object the
+        // relationship is with, and fail if we can't.
+        if (isset($rel['mapper'])) {
+            $m = new $rel['mapper']();
+        } else {
+            $m = $mapper->tableToMapper($field);
+            if (is_null($m)) {
+                return null;
+            }
+        }
+
+        // Based on the kind of relationship, fetch the appropriate
+        // objects and fill the cache.
+        switch ($rel['type']) {
+        case Horde_Rdo::ONE_TO_ONE:
+        case Horde_Rdo::MANY_TO_ONE:
+            if (isset($rel['query'])) {
+                $query = $this->_fillPlaceholders($rel['query']);
+                $this->_fields[$field] = $m->findOne($query);
+            } else {
+                $this->_fields[$field] = $m->find($this->{$rel['foreignKey']});
+            }
+            break;
+
+        case Horde_Rdo::ONE_TO_MANY:
+            $this->_fields[$field] = $this->cache($field,
+                $m->find(array($rel['foreignKey'] => $this->{$rel['foreignKey']})));
+            break;
+
+        case Horde_Rdo::MANY_TO_MANY:
+            $key = $mapper->model->key;
+            $query = new Horde_Rdo_Query();
+            $on = isset($rel['on']) ? $rel['on'] : $m->model->key;
+            $query->addRelationship($field, array('mapper' => $mapper,
+                                                     'table' => $rel['through'],
+                                                     'type' => Horde_Rdo::MANY_TO_MANY,
+                                                     'query' => array($on => new Horde_Rdo_Query_Literal($on), $key => $this->$key)));
+            $this->_fields[$field] = $m->find($query);
+            break;
+        }
+
+        return $this->_fields[$field];
+    }
+
+    /**
+     * Set a field's value.
+     *
+     * @param string $field The field to set
+     * @param mixed $value The field's new value
+     */
+    public function __set($field, $value)
+    {
+        // Honor any explicit setters.
+        $fieldMethod = 'set' . ucfirst($field);
+        // If an Rdo_Base subclass has a __call() method, is_callable
+        // returns true on every method name, so use method_exists
+        // instead.
+        if (method_exists($this, $fieldMethod)) {
+            return call_user_func(array($this, $fieldMethod), $value);
+        }
+
+        $this->_fields[$field] = $value;
+    }
+
+    /**
+     * Allow using isset($rdo->foo) to check for field or
+     * relationship presence.
+     *
+     * @param string $field The field name to check existence of.
+     */
+    public function __isset($field)
+    {
+        $m = $this->getMapper();
+        return isset($m->fields[$field])
+            || isset($m->lazyFields[$field])
+            || isset($m->relationships[$field])
+            || isset($m->lazyRelationships[$field]);
+    }
+
+    /**
+     * Allow using unset($rdo->foo) to unset a basic
+     * field. Relationships cannot be unset in this way.
+     *
+     * @param string $field The field name to unset.
+     */
+    public function __unset($field)
+    {
+        // @TODO Should unsetting a MANY_TO_MANY relationship remove
+        // the relationship?
+        unset($this->_fields[$field]);
+    }
+
+    /**
+     * Implement the IteratorAggregate interface. Looping over an Rdo
+     * object goes through each property of the object in turn.
+     *
+     * @return Horde_Rdo_Iterator The Iterator instance.
+     */
+    public function getIterator()
+    {
+        return new Horde_Rdo_Iterator($this);
+    }
+
+    /**
+     * Get a Mapper instance that can be used to manage this
+     * object. The Mapper instance can come from a few places:
+     *
+     * - If the class <RdoClassName>Mapper exists, it will be used
+     *   automatically.
+     *
+     * - Any Rdo instance created with Horde_Rdo_Mapper::map() will have a
+     *   $mapper object set automatically.
+     *
+     * - Subclasses can override getMapper() to return the correct
+     *   mapper object.
+     *
+     * - The programmer can call $rdoObject->setMapper($mapper) to provide a
+     *   mapper object.
+     *
+     * A Horde_Rdo_Exception will be thrown if none of these
+     * conditions are met.
+     *
+     * @return Horde_Rdo_Mapper The Mapper instance managing this object.
+     */
+    public function getMapper()
+    {
+        if (!$this->_mapper) {
+            $class = get_class($this) . 'Mapper';
+            if (class_exists($class)) {
+                $this->_mapper = new $class();
+            } else {
+                throw new Horde_Rdo_Exception('No Horde_Rdo_Mapper object found. Override getMapper() or define the ' . get_class($this) . 'Mapper class.');
+            }
+        }
+
+        return $this->_mapper;
+    }
+
+    /**
+     * Associate this Rdo object with the Mapper instance that will
+     * manage it. Called automatically by Horde_Rdo_Mapper:map().
+     *
+     * @param Horde_Rdo_Mapper $mapper The Mapper to manage this Rdo object.
+     *
+     * @see Horde_Rdo_Mapper::map()
+     */
+    public function setMapper($mapper)
+    {
+        $this->_mapper = $mapper;
+    }
+
+    /**
+     * Save any changes to the backend.
+     *
+     * @return boolean Success.
+     */
+    public function save()
+    {
+        return $this->getMapper()->update($this) == 1;
+    }
+
+    /**
+     * Delete this object from the backend.
+     *
+     * @return boolean Success or failure.
+     */
+    public function delete()
+    {
+        return $this->getMapper()->delete($this) == 1;
+    }
+
+    /**
+     * Take a query array and replace @field@ placeholders with values
+     * from this object.
+     *
+     * @param array $query The query to process placeholders on.
+     *
+     * @return array The query with placeholders filled in.
+     */
+    protected function _fillPlaceholders($query)
+    {
+        foreach (array_keys($query) as $field) {
+            $value = $query[$field];
+            if (preg_match('/^@(.*)@$/', $value, $matches)) {
+                $query[$field] = $this->{$matches[1]};
+            }
+        }
+
+        return $query;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Iterator.php b/framework/Rdo/lib/Horde/Rdo/Iterator.php
new file mode 100644 (file)
index 0000000..27f4c87
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * Iterator for Horde_Rdo_Base objects that allows relationships and
+ * decorated objects to be handled gracefully.
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+class Horde_Rdo_Iterator implements Iterator {
+
+    /**
+     * @var Horde_Rdo_Base
+     */
+    private $_rdo;
+
+    /**
+     * List of keys that we'll iterator over. This is the combined
+     * list of the fields, lazyFields, relationships, and
+     * lazyRelationships properties from the objects Horde_Rdo_Mapper.
+     */
+    private $_keys = array();
+
+    /**
+     * Current index
+     *
+     * @var mixed
+     */
+    private $_index = null;
+
+    /**
+     * Are we inside the array bounds?
+     *
+     * @var boolean
+     */
+    private $_valid = false;
+
+    /**
+     * New Horde_Rdo_Iterator for iterating over Rdo objects.
+     *
+     * @param Horde_Rdo_Base $rdo The object to iterate over
+     */
+    public function __construct($rdo)
+    {
+        $this->_rdo = $rdo;
+
+        $m = $rdo->getMapper();
+        $this->_keys = array_merge($m->fields,
+                                   $m->lazyFields,
+                                   array_keys($m->relationships),
+                                   array_keys($m->lazyRelationships));
+    }
+
+    /**
+     * Reset to the first key.
+     */
+    public function rewind()
+    {
+        $this->_valid = (false !== reset($this->_keys));
+    }
+
+    /**
+     * Return the current value.
+     *
+     * @return mixed The current value
+     */
+    public function current()
+    {
+        $key = $this->key();
+        return $this->_rdo->$key;
+    }
+
+    /**
+     * Return the current key.
+     *
+     * @return mixed The current key
+     */
+    public function key()
+    {
+        return current($this->_keys);
+    }
+
+    /**
+     * Move to the next key in the iterator.
+     */
+    public function next()
+    {
+        $this->_valid = (false !== next($this->_keys));
+    }
+
+    /**
+     * Check array bounds.
+     *
+     * @return boolean Inside array bounds?
+     */
+    public function valid()
+    {
+        return $this->_valid;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/List.php b/framework/Rdo/lib/Horde/Rdo/List.php
new file mode 100644 (file)
index 0000000..b8b56ad
--- /dev/null
@@ -0,0 +1,193 @@
+<?php
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * Iterator for collections of Rdo objects.
+ *
+ * @TODO implement ArrayAccess as well?
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+class Horde_Rdo_List implements Iterator {
+
+    /**
+     * Rdo Mapper
+     * @var Horde_Rdo_Mapper
+     */
+    protected $_mapper;
+
+    /**
+     * SQL query to run
+     * @var string
+     */
+    protected $_sql;
+
+    /**
+     * Bind parameters
+     * @var array
+     */
+    protected $_bindParams = array();
+
+    /**
+     * Result resource
+     * @var Iterator
+     */
+    protected $_result;
+
+    /**
+     * Current object
+     * @var Horde_Rdo_Base
+     */
+    protected $_current;
+
+    /**
+     * Current list offset.
+     * @var integer
+     */
+    protected $_index;
+
+    /**
+     * Are we at the end of the list?
+     * @var boolean
+     */
+    protected $_eof;
+
+    /**
+     * Constructor.
+     *
+     * @param mixed $query The query to run when results are
+     * requested. Can be a Horde_Rdo_Query object, a literal SQL
+     * query, or a tuple containing an SQL string and an array of bind
+     * parameters to use.
+     * @param Horde_Rdo_Mapper $mapper Mapper to create objects for this list from.
+     */
+    public function __construct($query, $mapper = null)
+    {
+        if ($query instanceof Horde_Rdo_Query) {
+            // Make sure we have a Mapper object, which can be passed
+            // implicitly by being set on the Query.
+            if (!$mapper) {
+                if (!$query->mapper) {
+                    throw new Horde_Rdo_Exception('Mapper must be set on the Query object or explicitly passed.');
+                }
+                $mapper = $query->mapper;
+            }
+
+            // Convert the query into a SQL statement and an array of
+            // bind parameters.
+            list($this->_sql, $this->_bindParams) = $mapper->adapter->dml->getQuery($query);
+        } elseif (is_string($query)) {
+            // Straight SQL query, empty bind parameters array.
+            $this->_sql = $query;
+            $this->_bindParams = array();
+        } else {
+            // $query is already an array with SQL and bind parameters.
+            list($this->_sql, $this->_bindParams) = $query;
+        }
+
+        // Keep a handle on the Mapper object for running the query.
+        $this->_mapper = $mapper;
+    }
+
+    /**
+     * Destructor - release any resources.
+     */
+    public function __destruct()
+    {
+        if ($this->_result) {
+            unset($this->_result);
+        }
+    }
+
+    /**
+     * Implementation of the rewind() method for iterator.
+     */
+    public function rewind()
+    {
+        if ($this->_result) {
+            unset($this->_result);
+        }
+        $this->_current = null;
+        $this->_index = null;
+        $this->_eof = true;
+        $this->_result = $this->_mapper->adapter->select($this->_sql, $this->_bindParams);
+
+        $this->next();
+    }
+
+    /**
+     * Implementation of the current() method for iterator.
+     *
+     * @return mixed The current row, or null if no rows.
+     */
+    public function current()
+    {
+        if (is_null($this->_result)) {
+            $this->rewind();
+        }
+        return $this->_current;
+    }
+
+    /**
+     * Implementation of the key() method for iterator.
+     *
+     * @return mixed The current row number (starts at 0), or NULL if no rows
+     */
+    public function key()
+    {
+        if (is_null($this->_result)) {
+            $this->rewind();
+        }
+        return $this->_index;
+    }
+
+    /**
+     * Implementation of the next() method.
+     *
+     * @return Horde_Rdo_Base|null The next Rdo object in the set or
+     * null if no more results.
+     */
+    public function next()
+    {
+        if (is_null($this->_result)) {
+            $this->rewind();
+        }
+
+        if ($this->_result) {
+            $row = $this->_result->fetch();
+            if (!$row) {
+                $this->_eof = true;
+            } else {
+                $this->_eof = false;
+
+                if (is_null($this->_index)) {
+                    $this->_index = 0;
+                } else {
+                    ++$this->_index;
+                }
+
+                $this->_current = $this->_mapper->map($row);
+            }
+        }
+
+        return $this->_current;
+    }
+
+    /**
+     * Implementation of the valid() method for iterator
+     *
+     * @return boolean Whether the iteration is valid
+     */
+    public function valid()
+    {
+        if (is_null($this->_result)) {
+            $this->rewind();
+        }
+        return !$this->_eof;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Mapper.php b/framework/Rdo/lib/Horde/Rdo/Mapper.php
new file mode 100644 (file)
index 0000000..e7cb7aa
--- /dev/null
@@ -0,0 +1,534 @@
+<?php
+/**
+ * Rdo Mapper base class.
+ *
+ * @category Horde
+ * @package Horde_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.
+ *
+ *   $model - The Horde_Rdo_Model object describing the main table of
+ *   this entity.
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+abstract class Horde_Rdo_Mapper implements Countable {
+
+    /**
+     * If this is true and fields named created and updated 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 name of the database table (or view, etc.) that holds this
+     * Mapper's objects.
+     *
+     * @var string
+     */
+    protected $_table;
+
+    /**
+     * 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;
+
+    /**
+     * 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.
+     *
+     * inflector: The Horde_Support_Inflector this mapper uses to singularize
+     * and pluralize PHP class, database table, and database field/key names.
+     *
+     * model: The Horde_Rdo_Model object describing the table or view
+     * this Mapper manages.
+     *
+     * fields: Array of all field names that are loaded up front
+     * (eager loading) from the Model.
+     *
+     * lazyFields: Array of fields that are only loaded when accessed.
+     *
+     * relationships: Array of relationships to other Models.
+     *
+     * lazyRelationships: Array of relationships to other Models 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 'adapter':
+            $this->adapter = $this->getAdapter();
+            return $this->adapter;
+
+        case 'inflector':
+            return Horde_Rdo::getInflector();
+
+        case 'model':
+            $this->model = new Horde_Rdo_Model;
+            if ($this->_table) {
+                $this->model->table = $this->_table;
+            } else {
+                $this->model->table = $this->mapperToTable();
+            }
+            $this->model->load($this);
+            return $this->model;
+
+        case 'fields':
+            $this->fields = array_diff($this->model->listFields(), $this->_lazyFields);
+            return $this->fields;
+
+        case 'lazyFields':
+        case 'relationships':
+        case 'lazyRelationships':
+        case 'defaultSort':
+            return $this->{'_' . $key};
+        }
+
+        return null;
+    }
+
+    /**
+     * Associate an adapter with this mapper. Not needed in the
+     * general case if getAdapter() is overridden in the concrete
+     * Mapper implementation.
+     *
+     * @param Horde_Db_Adapter $adapter Horde_Db_Adapter to store objects.
+     *
+     * @see getAdapter()
+     */
+    public function setAdapter($adapter)
+    {
+        $this->adapter = $adapter;
+    }
+
+    /**
+     * getAdapter() must be overridden by Horde_Rdo_Mapper subclasses
+     * if they don't provide $adapter in some other way (by calling
+     * setAdapter() or on construction, for example), and there is no
+     * global Adapter.
+     *
+     * @see setAdapter()
+     *
+     * @return Horde_Db_Adapter The adapter for storing this Mapper's objects.
+     */
+    public function getAdapter()
+    {
+        $adapter = Horde_Db::getAdapter();
+        if ($adapter) {
+            return $adapter;
+        }
+        throw new Horde_Rdo_Exception('You must override getAdapter(), assign a Horde_Db_Adapter by calling setAdapter(), or set a global adapter by calling Horde_Db::setAdapter().');
+    }
+
+    /**
+     * 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();
+        }
+
+        $relationships = array();
+        foreach ($fields as $fieldName => $fieldValue) {
+            if (strpos($fieldName, '@') !== false) {
+                list($rel, $field) = explode('@', $fieldName, 2);
+                $relationships[$rel][$field] = $fieldValue;
+                unset($fields[$fieldName]);
+            }
+        }
+
+        $o = new $this->_classname($fields);
+        $o->setMapper($this);
+
+        if (count($relationships)) {
+            foreach ($this->relationships as $relationship => $rel) {
+                if (isset($rel['mapper'])) {
+                    $m = new $rel['mapper']();
+                } else {
+                    $m = $this->tableToMapper($relationship);
+                    if (is_null($m)) {
+                        // @TODO Throw an exception?
+                        continue;
+                    }
+                }
+
+                if (isset($relationships[$m->model->table])) {
+                    $o->$relationship = $m->map($relationships[$m->model->table]);
+                }
+            }
+        }
+
+        if (is_callable(array($o, 'afterMap'))) {
+            $o->afterMap();
+        }
+
+        return $o;
+    }
+
+    /**
+     * 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 = 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(strtolower(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) = $this->adapter->dml->getCount($query);
+        return $this->adapter->selectOne($sql, $bindParams);
+    }
+
+    /**
+     * Check if at least one object matches $query.
+     *
+     * @param mixed $query Either a primary key, an array of keys
+     *                     => values, or an 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) = $this->adapter->dml->getQuery($query);
+        return (bool)$this->adapter->selectOne($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 to the initial creation time so it's
+        // always set.
+        if ($this->_setTimestamps) {
+            $time = gmmktime();
+            $fields['created'] = $time;
+            $fields['updated'] = $time;
+        }
+
+        // Filter out any extra fields.
+        $fields = array_intersect_key($fields, $this->model->getFields());
+
+        if (!$fields) {
+            throw new Horde_Rdo_Exception('create() requires at least one field value.');
+        }
+
+        $sql = 'INSERT INTO ' . $this->adapter->quoteColumnName($this->model->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(array($this->model->key => $id),
+                                      $fields));
+    }
+
+    /**
+     * 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->model->key;
+            $id = $object->$key;
+            $fields = iterator_to_array($object);
+
+            if (!$id) {
+                // Object doesn't exist yet; create it instead.
+                $object = $this->create($fields);
+                return 1;
+            }
+        } else {
+            $id = $object;
+        }
+
+        // If configured to record update time, set it here.
+        if ($this->_setTimestamps) {
+            $fields['updated'] = gmmktime();
+        }
+
+        // Filter out any extra fields.
+        $fields = array_intersect_key($fields, $this->model->getFields());
+
+        if (!$fields) {
+            // Nothing to change.
+            return true;
+        }
+
+        $sql = 'UPDATE ' . $this->adapter->quoteColumnName($this->model->table) . ' SET';
+        $bindParams = array();
+        foreach ($fields as $field => $value) {
+            $sql .= ' ' . $this->adapter->quoteColumnName($field) . ' = ?,';
+            $bindParams[] = $value;
+        }
+        $sql = substr($sql, 0, -1) . ' WHERE ' . $this->model->key . ' = ?';
+        $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->model->key;
+            $id = $object->$key;
+            $query = array($key => $id);
+        } elseif ($object instanceof Horde_Rdo_Query) {
+            $query = $object;
+        } else {
+            $key = $this->model->key;
+            $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->quoteColumnName($this->model->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.
+                $key = $this->model->key;
+                $query = new Horde_Rdo_Query();
+                $query->combineWith('OR');
+                foreach ($argv[0] as $id) {
+                    $query->addTest($key, '=', $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);
+    }
+
+    /**
+     * 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->model->key => $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;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Model.php b/framework/Rdo/lib/Horde/Rdo/Model.php
new file mode 100644 (file)
index 0000000..83d6a89
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+/**
+ * Model class for Rdo.
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+class Horde_Rdo_Model {
+
+    /**
+     */
+    protected $_fields = array();
+
+    /**
+     */
+    public $table;
+
+    /**
+     */
+    const INTEGER = 'int';
+
+    /**
+     */
+    const NUMBER = 'number';
+
+    /**
+     */
+    const STRING = 'string';
+
+    /**
+     */
+    const TEXT = 'text';
+
+    /**
+     * Fill the model using the mapper's backend.
+     */
+    public function load($mapper)
+    {
+        $mapper->adapter->loadModel($this);
+    }
+
+    /**
+     */
+    public static function __set_state($properties)
+    {
+        $model = new Horde_Rdo_Model();
+        foreach ($properties as $key => $val) {
+            $model->$key = $val;
+        }
+    }
+
+    /**
+     */
+    public function hasField($field)
+    {
+        return isset($this->_fields[$field]);
+    }
+
+    /**
+     */
+    public function addField($field, $params = array())
+    {
+        $params = array_merge(array('key' => null, 'null' => false), $params);
+
+        if (!strncasecmp($params['null'], 'n', 1)) {
+            $params['null'] = false;
+        } elseif (!strncasecmp($params['null'], 'y', 1)) {
+            $params['null'] = true;
+        }
+
+        $this->_fields[$field] = $params;
+        if (isset($params['type'])) {
+            $this->setFieldType($field, $params['type']);
+        }
+    }
+
+    /**
+     */
+    public function getField($field)
+    {
+        return isset($this->_fields[$field]) ? $this->_fields[$field] : null;
+    }
+
+    /**
+     */
+    public function getFields()
+    {
+        return $this->_fields;
+    }
+
+    /**
+     */
+    public function listFields()
+    {
+        return array_keys($this->_fields);
+    }
+
+    /**
+     */
+    public function setFieldType($field, $rawtype)
+    {
+        if (stripos($rawtype, 'int') !== false) {
+            $this->_fields[$field]['type'] = self::INTEGER;
+        } elseif (stripos($rawtype, 'char') !== false) {
+            $this->_fields[$field]['type'] = self::STRING;
+        } elseif (stripos($rawtype, 'float') !== false
+                  || stripos($rawtype, 'decimal') !== false) {
+            $this->_fields[$field]['type'] = self::NUMBER;
+        } elseif ($rawtype == 'text') {
+            $this->_fields[$field]['type'] = self::TEXT;
+        }
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Query.php b/framework/Rdo/lib/Horde/Rdo/Query.php
new file mode 100644 (file)
index 0000000..5ed2353
--- /dev/null
@@ -0,0 +1,313 @@
+<?php
+/**
+ * Represent a single query or a tree of many query elements uniformly to clients.
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+class Horde_Rdo_Query {
+
+    /**
+     * @var Horde_Rdo_Mapper
+     */
+    public $mapper;
+
+    /**
+     * @var string
+     */
+    public $conjunction = 'AND';
+
+    /**
+     * @var array
+     */
+    public $fields = array('*');
+
+    /**
+     * @var array
+     */
+    public $tests = array();
+
+    /**
+     * @var array
+     */
+    public $relationships = array();
+
+    /**
+     * @var array
+     */
+    protected $_sortby = array();
+
+    /**
+     * @var integer
+     */
+    public $limit;
+
+    /**
+     * @var integer
+     */
+    public $limitOffset = null;
+
+    /**
+     * Turn any of the acceptable query shorthands into a full
+     * Horde_Rdo_Query object. If you pass an existing Horde_Rdo_Query
+     * object in, it will be cloned before it's returned so that it
+     * can be safely modified.
+     *
+     * @param mixed $query The query to convert to an object.
+     * @param Horde_Rdo_Mapper $mapper The Mapper object governing this query.
+     *
+     * @return Horde_Rdo_Query The full Horde_Rdo_Query object.
+     */
+    public static function create($query, $mapper = null)
+    {
+        if ($query instanceof Horde_Rdo_Query ||
+            $query instanceof Horde_Rdo_Query_Literal) {
+            $query = clone($query);
+            if (!is_null($mapper)) {
+                $query->setMapper($mapper);
+            }
+            return $query;
+        }
+
+        $q = new Horde_Rdo_Query($mapper);
+
+        if (is_scalar($query)) {
+            $q->addTest($mapper->model->key, '=', $query);
+        } elseif ($query) {
+            $q->combineWith('AND');
+            foreach ($query as $key => $value) {
+                $q->addTest($key, '=', $value);
+            }
+        }
+
+        return $q;
+    }
+
+    /**
+     * @param  Horde_Rdo_Mapper  $mapper  Rdo mapper base class
+     */
+    public function __construct($mapper = null)
+    {
+        $this->setMapper($mapper);
+    }
+
+    /**
+     * @param Horde_Rdo_Mapper $mapper Rdo mapper base class
+     *
+     * @return Horde_Rdo_Query Return the query object for fluent chaining.
+     */
+    public function setMapper($mapper)
+    {
+        if ($mapper === $this->mapper) {
+            return $this;
+        }
+
+        $this->mapper = $mapper;
+
+        // Fetch all non-lazy-loaded fields for the mapper.
+        $this->setFields($mapper->fields, $mapper->model->table . '.');
+
+        if (!is_null($mapper)) {
+            // Add all non-lazy relationships.
+            foreach ($mapper->relationships as $relationship => $rel) {
+                if (isset($rel['mapper'])) {
+                    $m = new $rel['mapper']();
+                } else {
+                    $m = $this->mapper->tableToMapper($relationship);
+                    if (is_null($m)) {
+                        throw new Horde_Rdo_Exception('Unable to find a Mapper class for eager-loading relationship ' . $relationship);
+                    }
+                }
+
+                // Add the fields for this relationship to the query.
+                $this->addFields($m->fields, $m->model->table . '.@');
+
+                switch ($rel['type']) {
+                case Horde_Rdo::ONE_TO_ONE:
+                case Horde_Rdo::MANY_TO_ONE:
+                    if (isset($rel['query'])) {
+                        $query = $this->_fillJoinPlaceholders($m, $mapper, $rel['query']);
+                    } else {
+                        $query = array($mapper->model->table . '.' . $rel['foreignKey'] => new Horde_Rdo_Query_Literal($m->model->table . '.' . $m->model->key));
+                    }
+                    $this->addRelationship($relationship, array('mapper' => $m,
+                                                                'type' => $rel['type'],
+                                                                'query' => $query));
+                    break;
+                }
+            }
+        }
+
+        return $this;
+    }
+
+    /**
+     * @param array $fields The fields to load with this query.
+     *
+     * @return Horde_Rdo_Query Returns self for fluent method chaining.
+     */
+    public function setFields($fields, $fieldPrefix = null)
+    {
+        if (!is_array($fields)) {
+            $fields = array($fields);
+        }
+        if (!is_null($fieldPrefix)) {
+            array_walk($fields, array($this, '_prefix'), $fieldPrefix);
+        }
+        $this->fields = $fields;
+        return $this;
+    }
+
+    /**
+     * @param array $fields Additional Fields to load with this query.
+     *
+     * @return Horde_Rdo_Query Returns self for fluent method chaining.
+     */
+    public function addFields($fields, $fieldPrefix = null)
+    {
+        if (!is_null($fieldPrefix)) {
+            array_walk($fields, array($this, '_prefix'), $fieldPrefix);
+        }
+        $this->fields = array_merge($this->fields, $fields);
+    }
+
+    /**
+     * @param string $conjunction SQL conjunction such as "AND", "OR".
+     */
+    public function combineWith($conjunction)
+    {
+        $this->conjunction = $conjunction;
+        return $this;
+    }
+
+    /**
+     */
+    public function addTest($field, $test, $value)
+    {
+        $this->tests[] = array('field' => $field,
+                               'test'  => $test,
+                               'value' => $value);
+        return $this;
+    }
+
+    /**
+     */
+    public function addRelationship($relationship, $args)
+    {
+        if (!isset($args['mapper'])) {
+            throw new InvalidArgumentException('Relationships must contain a Horde_Rdo_Mapper object.');
+        }
+        if (!isset($args['table'])) {
+            $args['table'] = $args['mapper']->model->table;
+        }
+        if (!isset($args['type'])) {
+            $args['type'] = Horde_Rdo::MANY_TO_MANY;
+        }
+        if (!isset($args['join_type'])) {
+            switch ($args['type']) {
+            case Horde_Rdo::ONE_TO_ONE:
+            case Horde_Rdo::MANY_TO_ONE:
+                $args['join_type'] = 'INNER JOIN';
+                break;
+
+            default:
+                $args['join_type'] = 'LEFT JOIN';
+            }
+        }
+
+        $this->relationships[$relationship] = $args;
+        return $this;
+    }
+
+    /**
+     * Add a sorting rule.
+     *
+     * @param string $sort SQL sort fragment, such as 'updated DESC'
+     */
+    public function sortBy($sort)
+    {
+        $this->_sortby[] = $sort;
+        return $this;
+    }
+
+    /**
+     */
+    public function clearSort()
+    {
+        $this->_sortby = array();
+        return $this;
+    }
+
+    /**
+     * Restrict the query to a subset of the results.
+     *
+     * @param integer $limit Number of items to fetch.
+     * @param integer $offset Offset to start fetching at.
+     */
+    public function limit($limit, $offset = null)
+    {
+        $this->limit = $limit;
+        $this->limitOffset = $offset;
+        return $this;
+    }
+
+    /**
+     * Accessor for any fields that we want some logic around.
+     *
+     * @param string $key
+     */
+    public function __get($key)
+    {
+        switch ($key) {
+        case 'sortby':
+            if (!$this->_sortby && $this->mapper->defaultSort) {
+                // Add in any default sort values, if none are already
+                // set.
+                $this->sortBy($this->mapper->defaultSort);
+            }
+            return $this->_sortby;
+        }
+
+        throw new InvalidArgumentException('Undefined property ' . $key);
+    }
+
+    /**
+     * Callback for array_walk to prefix all elements of an array with
+     * a given prefix.
+     */
+    protected function _prefix(&$fieldName, $key, $prefix)
+    {
+        $fieldName = $prefix . $fieldName;
+    }
+
+    /**
+     * Take a query array and replace @field@ placeholders with values
+     * that will match in the load query.
+     *
+     * @param Horde_Rdo_Mapper $m1 Left-hand mapper
+     * @param Horde_Rdo_Mapper $m2 Right-hand mapper
+     * @param array $query The query to process placeholders on.
+     *
+     * @return array The query with placeholders filled in.
+     */
+    protected function _fillJoinPlaceholders($m1, $m2, $query)
+    {
+        $q = array();
+        foreach (array_keys($query) as $field) {
+            $value = $query[$field];
+            if (preg_match('/^@(.*)@$/', $value, $matches)) {
+                $q[$m1->model->table . '.' . $field] = new Horde_Rdo_Query_Literal($m2->model->table . '.' . $matches[1]);
+            } else {
+                $q[$m1->model->table . '.' . $field] = $value;
+            }
+        }
+
+        return $q;
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Query/Builder.php b/framework/Rdo/lib/Horde/Rdo/Query/Builder.php
new file mode 100644 (file)
index 0000000..36eea12
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * Horde_Rdo query building abstract base
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+abstract class Horde_Rdo_Query_Builder {
+
+    /**
+     */
+    public function getCount($query)
+    {
+        return $this->getQuery($query);
+    }
+
+    /**
+     * Query generator.
+     *
+     * @param Horde_Rdo_Query $query The query object to turn into SQL.
+     *
+     * @return array A two-element array of the SQL query and an array
+     * of bind parameters.
+     */
+    public function getQuery($query)
+    {
+        if ($query instanceof Horde_Rdo_Query_Literal) {
+            return array((string)$query, array());
+        }
+
+        $bindParams = array();
+        $sql = '';
+
+        $this->_select($query, $sql, $bindParams);
+        $this->_from($query, $sql, $bindParams);
+        $this->_join($query, $sql, $bindParams);
+        $this->_where($query, $sql, $bindParams);
+        $this->_orderBy($query, $sql, $bindParams);
+        $this->_limit($query, $sql, $bindParams);
+
+        return array($sql, $bindParams);
+    }
+
+    /**
+     * Return the database-specific version of a test.
+     *
+     * @param string $test The test to "localize"
+     */
+    public function getTest($test)
+    {
+        return $test;
+    }
+
+    /**
+     */
+    protected function _select($query, &$sql, &$bindParams)
+    {
+        $fields = array();
+        foreach ($query->fields as $field) {
+            $parts = explode('.@', $field, 2);
+            if (count($parts) == 1) {
+                $fields[] = $field;
+            } else {
+                $fields[] = str_replace('.@', '.', $field) . ' AS ' . $this->quoteColumnName($parts[0] . '@' . $parts[1]);
+            }
+        }
+
+        $sql = 'SELECT ' . implode(', ', $fields);
+    }
+
+    /**
+     */
+    protected function _from($query, &$sql, &$bindParams)
+    {
+        $sql .= ' FROM ' . $query->mapper->model->table;
+    }
+
+    /**
+     */
+    protected function _join($query, &$sql, &$bindParams)
+    {
+        foreach ($query->relationships as $relationship) {
+            $relsql = array();
+            foreach ($relationship['query'] as $key => $value) {
+                if ($value instanceof Horde_Rdo_Query_Literal) {
+                    $relsql[] = $key . ' = ' . (string)$value;
+                } else {
+                    $relsql[] = $key . ' = ?';
+                    $bindParams[] = $value;
+                }
+            }
+
+            $sql .= ' ' . $relationship['join_type'] . ' ' . $relationship['table'] . ' ON ' . implode(' AND ', $relsql);
+        }
+    }
+
+    /**
+     */
+    protected function _where($query, &$sql, &$bindParams)
+    {
+        $clauses = array();
+        foreach ($query->tests as $test) {
+            if (strpos($test['field'], '@') !== false) {
+                list($rel, $field) = explode('@', $test['field']);
+                if (!isset($query->relationships[$rel])) {
+                    continue;
+                }
+                $clause = $query->relationships[$rel]['table'] . '.' . $field . ' ' . $this->getTest($test['test']);
+            } else {
+                $clause = $query->mapper->model->table . '.' . $this->quoteColumnName($test['field']) . ' ' . $this->getTest($test['test']);
+            }
+
+            if ($test['value'] instanceof Horde_Rdo_Query_Literal) {
+                $clauses[] = $clause . ' ' . (string)$test['value'];
+            } else {
+                if ($test['test'] == 'IN' && is_array($test['value'])) {
+                    $clauses[] = $clause . '(?' . str_repeat(',?', count($test['value']) - 1) . ')';
+                    $bindParams = array_merge($bindParams, array_values($test['value']));
+                } else {
+                    $clauses[] = $clause . ' ?';
+                    $bindParams[] = $test['value'];
+                }
+            }
+        }
+
+        if ($clauses) {
+            $sql .= ' WHERE ' . implode(' ' . $query->conjunction . ' ', $clauses);
+        }
+    }
+
+    /**
+     */
+    protected function _orderBy($query, &$sql, &$bindParams)
+    {
+        if ($query->sortby) {
+            $sql .= ' ORDER BY';
+            foreach ($query->sortby as $sort) {
+                if (strpos($sort, '@') !== false) {
+                    /*@TODO parse these placeholders out, or drop them*/
+                    list($field, $direction) = $sort;
+                    list($rel, $field) = explode('@', $field);
+                    if (!isset($query->relationships[$rel])) {
+                        continue;
+                    }
+                    $sql .= ' ' . $query->relationships[$rel]['table'] . '.' . $field . ' ' . $direction . ',';
+                } else {
+                    $sql .= " $sort,";
+                }
+            }
+
+            $sql = substr($sql, 0, -1);
+        }
+    }
+
+    /**
+     */
+    protected function _limit($query, &$sql, &$bindParams)
+    {
+        if ($query->limit) {
+            $sql .= ' LIMIT ' . $query->limit;
+            if (!is_null($query->limitOffset)) {
+                $sql .= ' OFFSET ' . $query->limitOffset;
+            }
+        }
+    }
+
+}
diff --git a/framework/Rdo/lib/Horde/Rdo/Query/Literal.php b/framework/Rdo/lib/Horde/Rdo/Query/Literal.php
new file mode 100644 (file)
index 0000000..1703e77
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+/**
+ * @category Horde
+ * @package Horde_Rdo
+ */
+
+/**
+ * Horde_Rdo literal query string object.
+ *
+ * If you need to pass a string that should not be quoted into a
+ * Horde_Rdo_Query object, wrap it in a Horde_Rdo_Query_Literal object
+ * and it will not be quoted or escaped. Note that of course you need
+ * to be very careful about introducing user input or any other
+ * untrusted input into these objects.
+ *
+ * Example:
+ *   $literal = new Horde_Rdo_Query_Literal('MAX(column_name)');
+ *
+ * @category Horde
+ * @package Horde_Rdo
+ */
+class Horde_Rdo_Query_Literal {
+
+    /**
+     * SQL literal string.
+     *
+     * @var string
+     */
+    protected $_string;
+
+    /**
+     * @var Horde_Rdo_Mapper
+     */
+    public $mapper;
+
+    /**
+     * Instantiate a literal, which is just a string stored as
+     * an instance member variable.
+     *
+     * @param string $string The string containing an SQL literal.
+     * @param Horde_Rdo_Mapper $mapper The Mapper object governing this query.
+     */
+    public function __construct($string, $mapper = null)
+    {
+        $this->_string = (string)$string;
+        $this->setMapper($mapper);
+    }
+
+    /**
+     * @return string The SQL literal stored in this object.
+     */
+    public function __toString()
+    {
+        return $this->_string;
+    }
+
+    /**
+     * @param Horde_Rdo_Mapper $mapper Rdo mapper base class
+     *
+     * @return Horde_Rdo_Query Return the query object for fluent chaining.
+     */
+    public function setMapper($mapper)
+    {
+        if ($mapper === $this->mapper) {
+            return $this;
+        }
+
+        $this->mapper = $mapper;
+    }
+
+}