<?php
/**
 * The Category_sql:: class provides an SQL implementation of the Horde
 * category system.
 *
 * Required values for $params:
 *      'phptype'        The database type (ie. 'pgsql', 'mysql, etc.).
 *      'hostspec'       The hostname of the database server.
 *      'protocol'       The communication protocol ('tcp', 'unix', etc.).
 *      'username'       The username with which to connect to the database.
 *      'password'       The password associated with 'username'.
 *      'database'       The name of the database.
 *      'charset'        The charset used by the database.
 *
 * Optional values:
 *      'table'          The name of the data table in 'database'.
 *                       Defaults to 'horde_categories'.
 *
 * The table structure for the category system is in
 * horde/scripts/db/category.sql.
 *
 * $Horde: horde/lib/Category/sql.php,v 1.84 2003/07/13 13:26:09 mdjukic Exp $
 *
 * Copyright 1999-2003 Stephane Huther <shuther@bigfoot.com>
 * Copyright 2001-2003 Chuck Hagenbuch <chuck@horde.org>
 *
 * See the enclosed file COPYING for license information (LGPL). If you did not
 * receive this file, see http://www.fsf.org/copyleft/lgpl.html.
 *
 * @author  Chuck Hagenbuch <chuck@horde.org>
 * @author  Stephane Huther <shuther@bigfoot.com>
 * @version $Revision: 1.84 $
 * @since   Horde 2.1
 * @package horde.category
 */
class Category_sql extends Category {

    /**
     * Handle for the current database connection.
     * @var resource $_db
     */
    var $_db;

    /**
     * Boolean indicating whether or not we're connected to the SQL server.
     * @var boolean $_connected
     */
    var $_connected = false;

    /**
     * Constructs a new SQL category object.
     *
     * @param array $params  A hash containing connection parameters.
     */
    function Category_sql($params)
    {
        parent::Category($params);
        $this->_connect();
    }

    /**
     * Does the current categories backend have persistent storage?
     *
     * @return boolean  True if there is persistent storage, false if not.
     */
    function isPersistent()
    {
        return true;
    }

    /**
     * Load (a subset of) the category tree into the $_categories array.
     *
     * @access private
     *
     * @param optional string  $root    Which portion of the category tree to
     *                                  load. Defaults to all of it.
     * @param optional boolean $reload  Re-load already loaded values?
     *
     * @return mixed  True on success or a PEAR_Error on failure.
     */
    function _load($root = '-1', $reload = false)
    {
        /* Do NOT use Category::exists() here; that would cause an
           infinite loop. */
        if (!$reload &&
            (in_array($root, $this->_nameMap) ||
             (count($this->_categories) > 0 && $root == '-1'))) {
            return true;
        }
        if (!empty($root) && $root != '-1') {
            if (strstr($root, ':')) {
                $parts = explode(':', $root);
                $root = array_pop($parts);
            }
            $root = (string)$root;

            $query = sprintf('SELECT 0, category_id, category_parents FROM %s' .
                             ' WHERE category_name = %s AND group_uid = %s ORDER BY category_id',
                             $this->_params['table'],
                             $this->_db->quote($root),
                             $this->_db->quote($this->_params['group']));

            Horde::logMessage('SQL Query by Category_sql::_load(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $root = $this->_db->getAssoc($query);
            if (is_a($root, 'PEAR_Error') || count($root) == 0) {
                return $root;
            }

            $pstring = $root[0][1] . ':' . $root[0][0] . ':%';
            $pquery = '';
            if (!empty($root[0][1])) {
                $ids = substr($root[0][1], 1);
                $pquery = ' OR category_id in (' . str_replace(':', ', ', $ids) . ')';
            }
            $pquery .= ' OR category_parents = ' . $this->_db->quote(substr($pstring, 0, -2));

            $query = sprintf('SELECT category_id, category_name, category_parents, category_order FROM %s' .
                             ' WHERE (category_parents LIKE %s OR category_id = %s%s)'.
                             ' AND group_uid = %s',
                             $this->_params['table'],
                             $this->_db->quote($pstring),
                             $root[0][0],
                             $pquery,
                             $this->_db->quote($this->_params['group']));
        } else {
            $query = sprintf('SELECT category_id, category_name, category_parents, category_order FROM %s' .
                             ' WHERE group_uid = %s',
                             $this->_params['table'],
                             $this->_db->quote($this->_params['group']));
        }

        Horde::logMessage('SQL Query by Category_sql::_load(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $data = $this->_db->getAll($query);
        if (is_a($data, 'PEAR_Error')) {
            return $data;
        }

        return $this->set(CATEGORY_FORMAT_FETCH, $data, $this->_params['charset']);
    }

    /**
     * Load a set of categories identified by their unique IDs, and their
     * parents, into the $_categories array.
     *
     * @access private
     *
     * @param mixed $cids  The unique ID of the category to load, or an array of
     *                     category ids.
     *
     * @return mixed  True on success or a PEAR_Error on failure.
     */
    function _loadById($cids)
    {
        /* Make sure we have an array. */
        if (!is_array($cids)) {
            $cids = array($cids);
        }

        /* Don't load any that are already loaded. Also, make sure that
           everything in the $ids array that we are building is an integer. */
        $ids = array();
        foreach ($cids as $cid) {
            /* Do NOT use Category::exists() here; that would cause an
               infinite loop. */
            if (!isset($this->_categories[$cid])) {
                $ids[] = (int)$cid;
            }
        }

        /* If there are none left to load, return. */
        if (!count($ids)) {
            return true;
        }

        $query = sprintf('SELECT category_id, category_parents FROM %s' .
                         ' WHERE category_id IN (%s) AND group_uid = %s' .
                         ' ORDER BY category_id',
                         $this->_params['table'],
                         implode(', ', $ids),
                         $this->_db->quote($this->_params['group']));
        $parents = $this->_db->getAssoc($query);
        if (is_a($parents, 'PEAR_Error')) {
            return $parents;
        }

        $pquery = '';
        $pids = array();
        foreach ($parents as $cid => $parent) {
            /* Add each category id in $cids to the list we'll load. */
            $pids[] = (int)$cid;

            /* Load the parents of each of these. */
            $pquery .= ' OR category_parents = ' . $this->_db->quote($parent . ':' . $cid);

            /* If this is a non-top-level category, load siblings too. */
            if (!empty($parent)) {
                /* Strip off the beginning ':', explode and add to the mix. */
                $pids = array_merge($pids, explode(':', substr($parent, 1)));
            }
        }
        $pids = array_unique($pids);

        $query = sprintf('SELECT category_id, category_name, category_parents, category_order FROM %s' .
                         ' WHERE (category_id IN (%s)%s)'.
                         ' AND group_uid = %s ORDER BY category_id',
                         $this->_params['table'],
                         implode(', ', $pids),
                         $pquery,
                         $this->_db->quote($this->_params['group']));

        Horde::logMessage('SQL Query by Category_sql::_loadById(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $data = $this->_db->getAll($query);
        if (is_a($data, 'PEAR_Error')) {
            return $data;
        }

        return $this->set(CATEGORY_FORMAT_FETCH, $data, $this->_params['charset']);
    }

    /**
     * Add a category
     *
     * @param mixed $category   The category to add (string or CategoryObject).
     */
    function addCategory($category)
    {
        $this->_connect();

        $attributes = false;
        if (is_a($category, 'CategoryObject')) {
            $fullname = $category->getName();
            $order = $category->order;

            /* We handle category differently if we can map it to the
               horde_category_attributes table. */
            if (method_exists($category, '_toAttributes')) {
                $data = '';
                $ser = null;

                /* Set a flag for later so that we know to insert the
                   attribute rows. */
                $attributes = true;
            } else {
                require_once HORDE_BASE . '/lib/Serialize.php';
                $ser = SERIALIZE_UTF7_BASIC;
                $data = Horde_Serialize::serialize($category->getData(), $ser, NLS::getCharset());
            }
        } else {
            $fullname = $category;
            $order = null;
            $data = '';
            $ser = null;
        }

        if (strstr($fullname, ':')) {
            $parts = explode(':', $fullname);
            $parents = '';
            $pstring = '';
            $name = array_pop($parts);
            foreach ($parts as $par) {
                $pstring .= (empty($pstring) ? '' : ':') . $par;
                $pid = $this->getCategoryId($pstring);
                if (is_a($pid, 'PEAR_Error')) {
                    /* Auto-create parents. */
                    $pid = $this->addCategory($pstring);
                    if (is_a($pid, 'PEAR_Error')) {
                        return $pid;
                    }
                }
                $parents .= ':' . $pid;
            }
        } else {
            $name = $fullname;
            $parents = '';
            $pid = -1;
        }

        $id = $this->_db->nextId($this->_params['table']);
        if (is_a($id, 'PEAR_Error')) {
            Horde::logMessage($id, __FILE__, __LINE__, PEAR_LOG_ERR);
            return $id;
        }

        if (parent::exists($fullname)) {
            return PEAR::raiseError(_("Already exists"));
        }

        $query = sprintf('INSERT INTO %s (category_id, group_uid, category_name, category_order, category_data, user_uid, category_serialized, category_parents)' .
                         ' VALUES (%s, %s, %s, %s, %s, %s, %s, %s)',
                         $this->_params['table'],
                         (int)$id,
                         $this->_db->quote($this->_params['group']),
                         $this->_db->quote(String::convertCharset($name, NLS::getCharset(), $this->_params['charset'])),
                         is_null($order) ? 'NULL' : (int)$order,
                         $this->_db->quote($data),
                         $this->_db->quote(Auth::getAuth()),
                         (int)$ser,
                         $this->_db->quote($parents));

        Horde::logMessage('SQL Query by Category_sql::addCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            Horde::logMessage($result, __FILE__, __LINE__, PEAR_LOG_ERR);
            return $result;
        }

        $reorder = $this->reorderCategory($parents, $order, $id);
        if (is_a($reorder, 'PEAR_Error')) {
            Horde::logMessage($reorder, __FILE__, __LINE__, PEAR_LOG_ERR);
            return $reorder;
        }

        $result = parent::_addCategory($fullname, $id, $pid, $order);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        /* If we succesfully inserted the category and it supports being mapped
           to the attributes table, do that now: */
        if (!empty($attributes)) {
            $result = $this->updateCategoryData($category);
            if (is_a($result, 'PEAR_Error')) {
                return $result;
            }
        }

        return $id;
    }

    /**
     * Change order of subcategories within a category.
     *
     * @param string $parents  The parent category id string path.
     * @param mixed  $order    A specific new order position or an array
     *                         containing the new positions for the given
     *                         $parents category.
     * @param integer $cid     If provided indicates insertion of a new child
     *                         to the category, and will be used to avoid
     *                         incrementing it when shifting up all other
     *                         children's order. If not provided indicates
     *                         deletion, hence shift all other positions down
     *                         one.
     */
    function reorderCategory($parents, $order = null, $cid = null)
    {
        $pquery = '';
        if (!is_array($order) && !is_null($order)) {
            /* Single update (add/del). */
            if (is_null($cid)) {
                /* No category id given so shuffle down. */
                $direction = '-';
            } else {
                /* We have a category id so shuffle up. */
                $direction = '+';
                /* ... of course leaving the new insertion alone. */
                $pquery = sprintf(' AND category_id != %s', (int)$cid);
            }
            $query = sprintf('UPDATE %s SET category_order = category_order %s 1 WHERE category_parents = %s AND category_order >= %s',
                         $this->_params['table'],
                         $direction,
                         $this->_db->quote($parents),
                         (int)$order) . $pquery;

            Horde::logMessage('SQL Query by Category_sql::reorderCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $result = $this->_db->query($query);

        } elseif (is_array($order) && !empty($order)) {
            /* Multi update. */
            $query = sprintf('SELECT COUNT(category_id) FROM %s WHERE category_parents = %s GROUP BY category_parents',
                         $this->_params['table'],
                         $this->_db->quote($parents));

            Horde::logMessage('SQL Query by Category_sql::reorderCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $result = $this->_db->getOne($query);
            if (count($order) != $result) {
                Horde::fatal(PEAR::raiseError(_("Cannot reorder, number of entries supplied for reorder does not match number stored.")), __FILE__, __LINE__);
            }

            $o_key = 0;
            foreach ($order as $o_cid) {
                $query = sprintf('UPDATE %s SET category_order = %s WHERE category_id = %s',
                         $this->_params['table'],
                         (int)$o_key,
                         (int)$o_cid);

                Horde::logMessage('SQL Query by Category_sql::reorderCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
                $result = $this->_db->query($query);
                $o_key++;
            }
            $pid = $this->getCategoryId($parents);
            $this->_reorderCategory($pid, $order); // Reorder cache
        }
    }

    /**
     * Remove a category.
     *
     * @param mixed $category          The category to remove.
     * @param optional boolean $force  Force to remove every child
     */
    function removeCategory($category, $force = false)
    {
        $this->_connect();

        $id = $this->getCategoryId($category);
        $order = $this->getCategoryOrder($category);

        $query = sprintf('SELECT category_id FROM %s ' .
                         ' WHERE group_uid = %s AND category_parents LIKE %s' .
                         ' ORDER BY category_id',
                         $this->_params['table'],
                         $this->_db->quote($this->_params['group']),
                         $this->_db->quote('%:' . (int)$id . ''));

        Horde::logMessage('SQL Query by Category_sql::removeCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $children = $this->_db->getAll($query, DB_FETCHMODE_ASSOC);

        if (count($children)) {
            if ($force) {
                foreach ($children as $child) {
                    $cat = $this->getCategoryName($child['category_id']);
                    $result = !$this->removeCategory($cat,true);
                }
            } else {
                return PEAR::raiseError(sprintf(_("Cannot remove; children exist (%s)"), count($children)));
            }
        }

        /* Remove attributes for this category; */
        $query = sprintf('DELETE FROM %s WHERE category_id = %s',
                         $this->_params['table_attributes'],
                         (int)$id);

        Horde::logMessage('SQL Query by Category_sql::removeCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        $query = sprintf('DELETE FROM %s WHERE category_id = %s',
                         $this->_params['table'],
                         (int)$id);

        Horde::logMessage('SQL Query by Category_sql::removeCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $result = $this->_db->query($query);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        $parents = $this->getParentIdString($category);
        $reorder = $this->reorderCategory($parents, $order);
        if (is_a($reorder, 'PEAR_Error')) {
            return $reorder;
        }
        return is_a(parent::removeCategory($category), 'PEAR_Error') ? $id : true;
    }

    /**
     * Move a category to a new parent.
     *
     * @param mixed  $category   The category to move.
     * @param string $newparent  The new parent category. Defaults to the root.
     */
    function moveCategory($category, $newparent = null)
    {
        $this->_connect();

        $old_parent_path = $this->getParentIdString($category);
        $result = parent::moveCategory($category, $newparent);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }
        $id = $this->getCategoryId($category);
        $new_parent_path = $this->getParentIdString($category);

        /* Fetch the category being moved and all of its children, since we also
           need to update their parent paths to avoid creating orphans. */
        $query = sprintf('SELECT category_id, category_parents FROM %s' .
                         ' WHERE category_parents = %s OR category_parents LIKE %s OR category_id = %s',
                         $this->_params['table'],
                         $this->_db->quote($old_parent_path . ':' . $id),
                         $this->_db->quote($old_parent_path . ':' . $id . ':%'),
                         (int)$id);

        Horde::logMessage('SQL Query by Category_sql::moveCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $rowset = $this->_db->query($query);
        if (is_a($rowset, 'PEAR_Error')) {
            return $rowset;
        }

        /* Update each category, replacing old parent path with the new one. */
        while ($row = $rowset->fetchRow(DB_FETCHMODE_ASSOC)) {
            if (is_a($row, 'PEAR_Error')) {
                return $row;
            }

            $oquery = '';
            if ($row['category_id'] == $id) {
                $oquery = ', category_order = 0 ';
            }

            /* Do str_replace() only if this is not a first level category. */
            if (!empty($row['category_parents'])) {
                $ppath = str_replace($old_parent_path, $new_parent_path, $row['category_parents']);
            } else {
                $ppath = $new_parent_path;
            }
            $query = sprintf('UPDATE %s SET category_parents = %s' . $oquery . ' WHERE category_id = %s',
                             $this->_params['table'],
                             $this->_db->quote($ppath),
                             (int)$row['category_id']);

            Horde::logMessage('SQL Query by Category_sql::moveCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $result = $this->_db->query($query);
            if (is_a($result, 'PEAR_Error')) {
                return $result;
            }
        }

        $order = $this->getCategoryOrder($category);

        /* Shuffle down the old category order positions. */
        $reorder = $this->reorderCategory($old_parent_path, $order);

        /* Shuffle up the new category order positions. */
        $reorder = $this->reorderCategory($new_parent_path, 0, $id);

        return true;
    }

    /**
     * Change a category's name.
     *
     * @param mixed $old_category        The old category.
     * @param string $new_category_name  The new category name.
     */
    function renameCategory($old_category, $new_category_name)
    {
        $this->_connect();

        /* Do the cache renaming first */
        $result = parent::renameCategory($old_category, $new_category_name);
        if (is_a($result, 'PEAR_Error')) {
            return $result;
        }

        /* Get the category id and set up the sql query */
        $id = $this->getCategoryId($old_category);
        $query = sprintf('UPDATE %s SET category_name = %s' .
                         ' WHERE category_id = %s',
                         $this->_params['table'],
                         $this->_db->quote(String::convertCharset($new_category_name, NLS::getCharset(), $this->_params['charset'])),
                         (int)$id);

        Horde::logMessage('SQL Query by Category_sql::renameCategory(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $result = $this->_db->query($query);

        return is_a($result, 'PEAR_Error') ? $result : true;
    }

    /**
     * Retrieve data for a category from the category_data field.
     *
     * @param integer $cid  The category id to fetch.
     */
    function getCategoryData($cid)
    {
        require_once HORDE_BASE . '/lib/Serialize.php';

        $this->_connect();

        $query = sprintf('SELECT category_data, category_serialized FROM %s WHERE category_id = %s',
                         $this->_params['table'],
                         (int)$cid);

        Horde::logMessage('SQL Query by Category_sql::getCategoryData(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
        $row = $this->_db->getRow($query, DB_FETCHMODE_ASSOC);

        $data = Horde_Serialize::unserialize($row['category_data'], $row['category_serialized'], NLS::getCharset());
        /* Convert old category data to the new format. */
        if ($row['category_serialized'] == SERIALIZE_BASIC) {
            $data = String::convertCharset($data, NLS::getCharset(true));
        }
        return (is_null($data) || !is_array($data)) ? array() : $data;
    }

    /**
     * Retrieve data for a category from the horde_category_attributes table.
     *
     * @param integer $cid  The category id to fetch.
     */
    function getCategoryAttributes($cid)
    {
        $this->_connect();

        if (is_array($cid)) {
            $query = sprintf('SELECT category_id, attribute_name as name, attribute_key as "key", attribute_value as value FROM %s WHERE category_id IN (%s)',
                             $this->_params['table_attributes'],
                             implode(', ', $cid));

            Horde::logMessage('SQL Query by Category_sql::getCategoryAttributes(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $rows = $this->_db->getAll($query, DB_FETCHMODE_ASSOC);
            if (is_a($rows, 'PEAR_Error')) {
                return $rows;
            }

            $data = array();
            foreach ($rows as $row) {
                if (empty($data[$row['category_id']])) {
                    $data[$row['category_id']] = array();
                }
                $data[$row['category_id']][] = array('name' => $row['name'],
                                                     'key' => $row['key'],
                                                     'value' => $row['value']);
            }
            return $data;
        } else {
            $query = sprintf('SELECT attribute_name as name, attribute_key as "key", attribute_value as value FROM %s WHERE category_id = %s',
                             $this->_params['table_attributes'],
                             (int)$cid);

            Horde::logMessage('SQL Query by Category_sql::getCategoryAttributes(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            return $this->_db->getAll($query, DB_FETCHMODE_ASSOC);
        }
    }

    /**
     * Return a set of category ids based on a set of attribute criteria.
     *
     * @param array $criteria  The array of criteria.
     */
    function getCategoriesByAttributes($criteria)
    {
        if (!count($criteria)) {
            return array();
        }

        /* Build the query. */
        $query = '';
        foreach ($criteria as $key => $vals) {
            if ($key == 'OR' || $key == 'AND') {
                if (!empty($query)) {
                    $query .= ' ' . $key . ' ';
                }
                $query .= '(' . $this->_buildAttributeQuery($key, $vals) . ')';
            }
        }

        $query = sprintf('SELECT DISTINCT a.category_id, c.category_name FROM %s a LEFT JOIN horde_categories c ON a.category_id = c.category_id' .
                         ' WHERE c.group_uid = %s AND %s',
                         $this->_params['table_attributes'],
                         $this->_db->quote($this->_params['group']),
                         $query);

        Horde::logMessage('SQL Query by Category_sql::getCategoriesByAttributes(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);

        return $this->_db->getAssoc($query);
    }

    /**
     * Build a piece of an attribute query.
     *
     * @param string $glue      The glue to join the criteria (OR/AND). 
     * @param array  $criteria  The array of criteria.
     *
     * @return string  An SQL query.
     */
    function _buildAttributeQuery($glue, $criteria)
    {
        require_once HORDE_BASE . '/lib/SQL.php';

        $clause = '';
        foreach ($criteria as $key => $vals) {
            if (!empty($vals['OR']) || !empty($vals['AND'])) {
                if (!empty($clause)) {
                    $clause .= ' ' . $glue . ' ';
                }
                $clause .= '(' . $this->_buildAttributeQuery($glue, $vals) . ')';
            } else {
                if (isset($vals['field'])) {
                    if (!empty($clause)) {
                        $clause .= ' ' . $glue . ' ';
                    }
                    $clause .= Horde_SQL::buildClause($this->_db, 'attribute_' . $vals['field'], $vals['op'], $vals['test']);
                } else {
                    foreach ($vals as $test) {
                        if (!empty($clause)) {
                            $clause .= ' ' . $key . ' ';
                        }
                        $clause .= Horde_SQL::buildClause($this->_db, 'attribute_' . $test['field'], $test['op'], $test['test']);
                    }
                }
            }
        }

        return $clause;
    }

    /**
     * Update the data in a category. Does not change the category's parent or
     * name, just serialized data or attributes.
     *
     * @param string $category  The category object.
     */
    function updateCategoryData($category)
    {
        $this->_connect();

        if (!is_a($category, 'CategoryObject')) {
            /* Nothing to do for non objects. */
            return true;
        }

        /* Get the category id. */
        $id = $this->getCategoryId($category->getName());

        /* See if we can break the object out to category_attributes table. */
        if (method_exists($category, '_toAttributes')) {
            /* If we do, clear out category_data field to make sure it doesn't
               get fallen back to by getCategoryData(). Explicitly don't check
               for errors here in case category_data goes away in the future. */
            $query = sprintf('UPDATE %s SET category_data = NULL WHERE category_id = %s',
                             $this->_params['table'],
                             (int)$id);

            Horde::logMessage('SQL Query by Category_sql::updateCategoryData(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $this->_db->query($query);

            /* Start a transaction. */
            $this->_db->autoCommit(false);

            /* Delete old attributes. */
            $query = sprintf('DELETE FROM %s WHERE category_id = %s',
                             $this->_params['table_attributes'],
                             (int)$id);

            Horde::logMessage('SQL Query by Category_sql::updateCategoryData(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $result = $this->_db->query($query);
            if (is_a($result, 'PEAR_Error')) {
                $this->_db->rollback();
                $this->_db->autoCommit(true);
                return $result;
            }

            /* Get the new attribute set, and insert each into the DB. If
               anything fails in here, rollback the transaction, return the
               relevant error, and bail out. */
            $attributes = $category->_toAttributes();
            foreach ($attributes as $attr) {
                $query = sprintf('INSERT INTO %s (category_id, attribute_name, attribute_key, attribute_value) VALUES (%s, %s, %s, %s)',
                                 $this->_params['table_attributes'],
                                 (int)$id,
                                 $this->_db->quote($attr['name']),
                                 $this->_db->quote($attr['key']),
                                 $this->_db->quote($attr['value']));

                Horde::logMessage('SQL Query by Category_sql::updateCategoryData(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
                $result = $this->_db->query($query);
                if (is_a($result, 'PEAR_Error')) {
                    $this->_db->rollback();
                    $this->_db->autoCommit(true);
                    return $result;
                }
            }

            /* Commit the transaction, and turn autocommit back on. */
            $result = $this->_db->commit();
            $this->_db->autoCommit(true);

            return is_a($result, 'PEAR_Error') ? $result : true;
        } else {
            /* Write to the category_data field. */
            require_once HORDE_BASE . '/lib/Serialize.php';
            $ser = SERIALIZE_UTF7_BASIC;
            $data = Horde_Serialize::serialize($category->getData(), $ser, NLS::getCharset());

            $query = sprintf('UPDATE %s SET category_data = %s, category_serialized = %s' .
                             ' WHERE category_id = %s',
                             $this->_params['table'],
                             $this->_db->quote($data),
                             (int)$ser,
                             (int)$id);

            Horde::logMessage('SQL Query by Category_sql::updateCategoryData(): ' . $query, __FILE__, __LINE__, PEAR_LOG_DEBUG);
            $result = $this->_db->query($query);

            return is_a($result, 'PEAR_Error') ? $result : true;
        }
    }

    /**
     * Attempts to open a connection to the SQL server.
     *
     * @return boolean  True.
     */
    function _connect()
    {
        if (!$this->_connected) {
            if (!is_array($this->_params)) {
                Horde::fatal(PEAR::raiseError(_("No configuration information specified for SQL Categories.")), __FILE__, __LINE__);
            }

            $required = array('phptype', 'hostspec', 'username', 'password',
                              'database');
            foreach ($required as $val) {
                if (!array_key_exists($val, $this->_params)) {
                    Horde::fatal(PEAR::raiseError(sprintf(_("Required '%s' not specified in categories configuration."), $val)), __FILE__, __LINE__);
                }
            }

            if (!array_key_exists('table', $this->_params)) {
                $this->_params['table'] = 'horde_categories';
            }

            if (!array_key_exists('table_attributes', $this->_params)) {
                $this->_params['table_attributes'] = 'horde_category_attributes';
            }

            /* Connect to the SQL server using the supplied
             * parameters. */
            require_once 'DB.php';
            $this->_db = &DB::connect($this->_params,
                                      array('persistent' => !empty($this->_params['persistent'])));
            if (is_a($this->_db, 'PEAR_Error')) {
                Horde::fatal($this->_db, __FILE__, __LINE__);
            }

            /* Enable the "portability" option. */
            $this->_db->setOption('optimize', 'portability');
            $this->_connected = true;
        }

        return true;
    }

}
