<?php
/*
 * $RCSfile: DatabaseStorage.class,v $
 *
 * Gallery - a web based photo album viewer and editor
 * Copyright (C) 2000-2005 Bharat Mediratta
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or (at
 * your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street - Fifth Floor, Boston, MA  02110-1301, USA.
 */
/**
 * @version $Revision: 1.128 $ $Date: 2005/08/26 23:47:06 $
 * @package GalleryCore
 * @author Bharat Mediratta <bharat@menalto.com>
 */

/**
 * Require the ADOdb libraries
 */
GalleryCoreApi::relativeRequireOnce('lib/adodb/adodb.inc.php');
GalleryCoreApi::relativeRequireOnce(
    'modules/core/classes/GalleryStorage/DatabaseStorage/ErrorHandler.inc');

/**
 * Name of the sequence we'll use for GalleryEntity ids
 */
define('DATABASE_SEQUENCE_ID', 'SequenceId');

/**
 * Name of the sequence we'll use for lock ids
 */
define('DATABASE_SEQUENCE_LOCK', 'SequenceLock');

/**
 * Default prefix to prepend to table names
 */
define('DATABASE_TABLE_PREFIX', 'g2_');

/**
 * Default prefix to prepend to column names
 */
define('DATABASE_COLUMN_PREFIX', 'g_');

/**
 * Database implementation of the GalleryStorage interface.
 *
 * This strategy implements the hooks for saving and restoring GalleryEntity
 * objects in a relational database.
 *
 * @package GalleryCore
 * @subpackage Storage
 */
class DatabaseStorage /* implements GalleryStorage */ {

    /**
     * Internal pointer to ADOdb database object
     *
     * @var object ADOdb $_db
     * @access private
     */
    var $_db;

    /**
     * Internal pointer to a non-transactional ADOdb database object
     *
     * @var object ADOdb $_nonTransactionalDb
     * @access private
     */
    var $_nonTransactionalDb;

    /**
     * Name of the database user
     *
     * @var string $_username
     * @access private
     */
    var $_username;

    /**
     * Password for the database user
     *
     * @var string $_password
     * @access private
     */
    var $_password;

    /**
     * Name of the database to use
     *
     * @var string $_database
     * @access private
     */
    var $_database;

    /**
     * Host the database runs on
     *
     * @var string $_hostname
     * @access private
     */
    var $_hostname;

    /**
     * A DatabaseSchema instance, used to check and update the schema
     *
     * @var object DatabaseSchema an instance of the DatabaseSchema class
     * @access private
     */
    var $_databaseSchema;

    /**
     * Are we attempting to be transactional?
     *
     * @var string $_transactional
     * @access private
     */
    var $_isTransactional;

    /**
     * A string to prepend to table names
     *
     * @var string $_tablePrefix
     * @access private
     */
    var $_tablePrefix;

    /**
     * A string to prepend to column names
     *
     * @var string $_columnPrefix
     * @access private
     */
    var $_columnPrefix;

    /**
     * A cache of member info that we've discovered about various classes
     *
     * @var array $_entityInfoCache
     * @access private
     */
    var $_entityInfoCache;

    /**
     * A cache of member info that we've discovered about various maps
     *
     * @var array $_mapInfoCache
     * @access private
     */
    var $_mapInfoCache;

    /**
     * Whether or not we should use persistent database connections
     *
     * @var array $_usePersistentConnections
     * @access private
     */
    var $_usePersistentConnections;

    /**
     * Constructor.
     *
     * @param array database configuration values
     */
    function DatabaseStorage($config) {
	$this->_username = $config['username'];
	$this->_password = $config['password'];
	$this->_hostname = $config['hostname'];
	$this->_database = $config['database'];
	$this->_isTransactional = false;

	/*
	 * We use persistent connections if the value is left out, or if it's
	 * non empty.
	 */
	$this->_usePersistentConnections = !isset($config['usePersistentConnections']) ||
	    !empty($config['usePersistentConnections']);

	if (isset($config['tablePrefix'])) {
	    $this->_tablePrefix = $config['tablePrefix'];
	} else {
	    $this->_tablePrefix = DATABASE_TABLE_PREFIX;
	}

	if (isset($config['columnPrefix'])) {
	    $this->_columnPrefix = $config['columnPrefix'];
	} else {
	    $this->_columnPrefix = DATABASE_COLUMN_PREFIX;
	}
    }

    /**
     * Do any initialization that is required by this class
     *
     * @return object GallerySatus a status code
     */
    function init() {
	global $gallery;

	list ($ret, $this->_db) = $this->_getConnection();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Connect to the database
     *
     * @return array object GalleryStatus a status code
     *               object a database resource
     * @access private
     */
    function _getConnection($forceNew=false) {
	global $gallery;

	$this->_traceStart();
	$db =& ADONewConnection($this->getAdoDbType());
	$this->_traceStop();
	if (empty($db)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}

	/* Turn on debugging in the database connection if Gallery is in debug mode */
	if ($gallery->getDebug()) {
	    $db->debug = true;
	}

	$this->_traceStart();
	if ($forceNew || !$this->_usePersistentConnections) {
	    $connectMethod = 'NConnect';
	} else {
	    $connectMethod = 'PConnect';
	}
	$ret = $db->$connectMethod($this->_hostname,
				   $this->_username,
				   $this->_password,
				   $this->_database);
	$this->_traceStop();

	if (!$ret) {
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}

	if ($gallery->isProfiling('sql')) {
	    $this->_traceStart();
	    $db->LogSQL();
	    $this->_traceStop();
	}

	return array(GalleryStatus::success(), $db);
    }

    /**
     * Return a non transactional database connection
     *
     * @return array object GalleryStatus a status code
     *               object ADOdb a database connection
     */
    function _getNonTransactionalDatabaseConnection() {
	/*
	 * If we're transactional, then we need another connection to
	 * manipulate our locks, since they have to operate outside of a
	 * transaction.
	 */
	if ($this->_isTransactional) {
	    if (empty($this->_nonTransactionalDb)) {
		list ($ret, $this->_nonTransactionalDb) = $this->_getConnection(true);
		if ($ret->isError()) {
		    return array($ret->wrap(__FILE__, __LINE__), null);
		}
	    }
	    return array(GalleryStatus::success(), $this->_nonTransactionalDb);
	} else {
	    return array(GalleryStatus::success(), $this->_db);
	}
    }

    /**
     * Load the GalleryEntities with the ids specified
     *
     * @param array the ids of the GalleryEntities to load
     * @return array object GalleryStatus a status code,
     *               mixed one GalleryEntity or an array of GalleryEntities
     */
    function loadEntities($ids) {
	global $gallery;

	if (empty($this->_db)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__), null);
	}

	/* Identify all the ids at once */
	list ($ret, $types) = $this->_identifyEntities($ids);
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	/* Separate the ids by type */
	$classNames = array();
	$gallery->guaranteeTimeLimit(5);
	for ($i = 0; $i < sizeof($ids); $i++) {
	    if (empty($types[$i])) {
		return array(GalleryStatus::error(ERROR_MISSING_OBJECT, __FILE__, __LINE__,
						  "Missing object for id $ids[$i]"), null);
	    }
	    $classNames[$types[$i]][$ids[$i]] = 1;
	}

	/* Load them in groups */
	foreach ($classNames as $className => $targetIdHash) {
	    $gallery->guaranteeTimeLimit(5);

	    /* Get unique target ids */
	    $targetIds = array_keys($targetIdHash);

	    /* Get our member info for this class */
	    list ($ret, $memberInfo) = $this->_describeEntityMembers($className);
	    if ($ret->isError()) {
		return array($ret->wrap(__FILE__, __LINE__), null);
	    }

	    $idCol = $this->_translateColumnName('id');

	    /* Build up our query */
	    $columns = array();
	    $tables = array();
	    $where = array();
	    $types = array();
	    $callbacks = array();
	    $markers = GalleryUtilities::makeMarkers(sizeof($targetIds));
	    foreach ($memberInfo['members'] as $columnName => $columnInfo) {
		list ($tableName, $unused) = $this->_translateTableName($columnInfo['class']);
		$types[] = $columnInfo['type'];
		/* Don't use ucfirst as it may be affected by current locale */
		$callbacks[] = 'set' . strtr($columnName{0}, 'abcdefghijklmnopqrstuvwxyz',
		    'ABCDEFGHIJKLMNOPQRSTUVWXYZ') . substr($columnName, 1);
		$columns[$tableName . '.' . $this->_translateColumnName($columnName)] = 1;
		$tables[$tableName] = 1;
	    }
	    $tables = array_keys($tables);
	    $columns = array_keys($columns);

	    for ($i = 0; $i < sizeof($tables); $i++) {
		if ($i == 0) {
		    $where[] = $tables[$i] . '.' . $idCol . ' IN (' . $markers . ')';
		} else {
		    $where[] = $tables[$i] . '.' . $idCol . '=' . $tables[0] . '.' . $idCol;
		}
	    }

	    $query = 'SELECT ';
	    $query .= join(', ', $columns);
	    $query .= ' FROM ';
	    $query .= join(', ', $tables);
	    $query .= ' WHERE ';
	    $query .= join(' AND ', $where);

	    /* Execute the query */
	    $GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	    $this->_traceStart();
	    $recordSet = $this->_db->Execute($query, $targetIds);
	    $this->_traceStop();
	    if ($recordSet) {
		if ($recordSet->RecordCount() != sizeof($targetIds)) {
		    return array(GalleryStatus::error(ERROR_MISSING_OBJECT, __FILE__, __LINE__),
				 null);
		}

		/* Process all the results */
		$j = 0;
		while ($row = $recordSet->FetchRow()) {
		    if (++$j % 20 == 0) {
			$gallery->guaranteeTimeLimit(5);
		    }
		    $entity = new $className;
		    if (empty($entity)) {
			return array(GalleryStatus::error(ERROR_BAD_DATA_TYPE, __FILE__, __LINE__),
				     null);
		    }

		    for ($i = 0; $i < sizeof($callbacks); $i++) {
			$value = $row[$i];

			/* Convert the database representation to a timestamp */
			if ($types[$i] & STORAGE_TYPE_TIMESTAMP) {
			    $value = $this->_db->UnixTimeStamp($value);
			}

			/* Store the value in the object */
			$entity->$callbacks[$i]($value);
		    }

		    $entity->clearModifiedFlags();
		    $entities[$entity->getId()] = $entity;
		}

		$recordSet->Close();
	    } else {
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }
	}

	/* Assemble the entities in the right order and return them */
	$result = array();
	foreach ($ids as $id) {
	    $result[] = $entities[$id];
	}

	return array(GalleryStatus::success(), $result);
    }

    /**
     * Save the changes to the GalleryEntity.
     *
     * @access public
     * @param object GalleryEntity reference to the GalleryEntity to save
     * @return object GalleryStatus a status code
     */
    function saveEntity(&$entity) {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	/*
	 * Update the serial number, but remember the original one
	 */
	$originalSerialNumber = $entity->getSerialNumber();
	$entity->setSerialNumber($originalSerialNumber + 1);

	/*
	 * Get our member info for this class
	 */
	list ($ret, $memberInfo) = $this->_describeEntityMembers($entity->getEntityType());
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}
	$idColumn = null;

	/*
	 * Build up a complete picture of all the various changed fields, so
	 * that we can do an insert or update.
	 */
	$dataTable = array();
	$id = array();
	foreach ($memberInfo['members'] as $memberName => $memberData) {
	    $type = $memberData['type'];
	    $class = $memberData['class'];
	    list ($tableName, $unused) = $this->_translateTableName($class);

	    /* If the member is modified, record the new value in our table */
	    if ($entity->getModifiedFlag($memberName) & MEMBER_MODIFIED) {
		$getFunc = 'get' . $memberName;
		$value = $entity->$getFunc();

		/* Convert empty values to null */
		if (!is_numeric($value) && empty($value)) {
		    $value = null;
		}

		/* Convert timestamps to the database representation */
		if ($type & STORAGE_TYPE_TIMESTAMP) {
		    $value = $this->_db->DBTimeStamp($value);
		}

		$columnName = $this->_translateColumnName($memberName);
		$dataTable[$tableName][$columnName] = $value;
	    } else {
		/*
		 * If we haven't set up a table for this class, do so now.
		 * Otherwise we don't have a complete list of tables that we
		 * need to insert into in order for this class to be completely
		 * serialized.
		 */
		if (!isset($dataTable[$tableName])) {
		    $dataTable[$tableName] = array();
		}
	    }

	    if ($type & STORAGE_TYPE_ID) {
		$getFunc = 'get' . $memberName;
		$value = $entity->$getFunc();

		$id['column'] = $this->_translateColumnName($memberName);
		$id['value'] = $value;
	    }
	}

	if ($entity->testPersistentFlag(STORAGE_FLAG_NEWLY_CREATED)) {
	    /*
	     * Iterate through the data table and make up an INSERT statement
	     * for each table that requires one.
	     */
	    foreach ($dataTable as $tableName => $columnChanges) {

		/* Make sure that the id column is set for each table */
		if (empty($columnChanges[$id['column']])) {
		    $columnChanges[$id['column']] = $id['value'];
		}

		$columns = array_keys($columnChanges);
		$data = array_values($columnChanges);
		$markers = GalleryUtilities::makeMarkers(sizeof($columnChanges));
		$query = 'INSERT INTO ' . $tableName . ' (';
		$query .= join(', ', $columns);
		$query .= ') VALUES (' . $markers . ')';

		$this->_traceStart();
		$recordSet = $this->_db->Execute($query, $data);
		$this->_traceStop();

		if (!$recordSet) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }
	} else {
	    /*
	     * Iterate through the data table and make an UPDATE statement for
	     * each table that requires one.  Make sure that we do the table
	     * that has the serial number in it first, as we use the serial
	     * number to make sure that we're not hitting a concurrency issue.
	     */
	    $serialNumberClass = $memberInfo['members']['serialNumber']['class'];
	    list ($serialNumberTable, $unused) = $this->_translateTableName($serialNumberClass);

	    $queryList = array();
	    foreach ($dataTable as $tableName => $columnChanges) {
		$changeList = array();
		$data = array();

		foreach ($columnChanges as $columnName => $value) {
		    $changeList[] = $columnName . '=?';
		    $data[] = $value;
		}

		if (sizeof($changeList)) {
		    $query = 'UPDATE ' . $tableName  .  ' SET';
		    $query .= ' ' . join(',', $changeList);
		    $query .= ' WHERE ' . $id['column'] . '=?';
		    $data[] = $id['value'];

		    if (!strcmp($tableName, $serialNumberTable)) {
			$query .= ' AND ' .
				$this->_translateColumnName('serialNumber') .
				'=?';
			$data[] = $originalSerialNumber;
			array_unshift($queryList, array($query, $data));
		    } else {
			array_push($queryList, array($query, $data));
		    }
		}
	    }

	    /*
	     * Now apply each UPDATE statement in turn.  Make sure that we're
	     * only affecting one row each time.
	     */
	    foreach ($queryList as $queryAndData) {
		list($query, $data) = $queryAndData;

		$this->_traceStart();
		$recordSet = $this->_db->Execute($query, $data);
		$this->_traceStop();

		if (!$recordSet) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		} else {
		    $affectedRows = $this->_db->Affected_Rows();
		    if ($affectedRows == 0) {
			return GalleryStatus::error(ERROR_OBSOLETE_DATA, __FILE__, __LINE__,
						    "$query (" . implode('|', $data) . ')');
		    } else if ($affectedRows > 1) {
			/* Holy shit, we just updated more than one row!  What do we do now? */
			return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__,
					      "$query (" . implode('|', $data) . ") $affectedRows");
		    }
		}
	    }
	}

	$entity->clearPersistentFlag(STORAGE_FLAG_NEWLY_CREATED);
	$entity->clearModifiedFlags();
	$ret = $entity->onSave();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Delete the GalleryEntity.
     *
     * @access public
     * @param object GalleryEntity the GalleryEntity to delete
     * @return object GalleryStatus a status code
     */
    function deleteEntity(&$entity) {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	/*
	 * If this object has not yet been saved in the database, don't bother
	 * saving it.
	 */
	if ($entity->testPersistentFlag(STORAGE_FLAG_NEWLY_CREATED)) {
	    $entity->clearPersistentFlag(STORAGE_FLAG_NEWLY_CREATED);
	    $entity->setPersistentFlag(STORAGE_FLAG_DELETED);
	    return GalleryStatus::success();
	}

	/*
	 * Get our persistent and member info for this class
	 */
	list ($ret, $memberInfo) =
	    $this->_describeEntityMembers($entity->getEntityType());
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	$idCol = $this->_translateColumnName('id');

	$tables = array();
	foreach ($memberInfo['members'] as $columnName => $columnInfo) {
	    list ($tableName, $unused) = $this->_translateTableName($columnInfo['class']);
	    $tables[$tableName] = 1;
	}

	/*
	 * XXX OPT:  Override this for specific database implementations that
	 * allow multi-table delete.
	 */
	foreach ($tables as $tableName => $junk) {
	    $query = 'DELETE FROM ' . $tableName .
		    ' WHERE ' . $idCol . '=?';
	    $data = array($entity->getId());

	    $this->_traceStart();
	    $recordSet = $this->_db->Execute($query, $data);
	    $this->_traceStop();
	    if (!$recordSet) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	}

	$entity->setPersistentFlag(STORAGE_FLAG_DELETED);

	return GalleryStatus::success();
    }

    /**
     * Create a new GalleryEntity
     *
     * @access public
     * @param object GalleryEntity the GalleryEntity to put the data in
     * @return object GalleryStatus a status code
     */
    function newEntity(&$entity) {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	list ($ret, $id) = $this->getUniqueId();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	$entity->setId($id);
	$entity->setSerialNumber(0);
	$entity->setPersistentFlag(STORAGE_FLAG_NEWLY_CREATED);

	return GalleryStatus::success();
    }

    /**
     * @see GalleryStorage::getUniqueId
     */
    function getUniqueId() {
	if (empty($this->_db)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__), null);
	}

	/* In case we're embedded in an app that sets adodb hasGenID to false (xaraya/postnuke) */
	if (isset($this->_db->hasGenID) && !$this->_db->hasGenID) {
	    $this->_db->hasGenID = $setGenID = true;
	}

	/* Get the id of the next object from our sequence */
	$this->_traceStart();
	$id = (int)$this->_db->GenId($this->_tablePrefix . DATABASE_SEQUENCE_ID);
	$this->_traceStop();

	if (isset($setGenID)) {
	    $this->_db->hasGenID = false;
	}

	return array(GalleryStatus::success(), $id);
    }

    /**
     * @see GalleryStorage::refreshEntity
     */
    function refreshEntity($entity) {
	if (empty($this->_db)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__), null);
	}

	/*
	 * We could check the serial number against the database, or check to
	 * see if the entity is modified in order to figure out whether or not
	 * we should refresh.  But either way that requires a database hit so we
	 * might as well just retrieve the record every time
	 */
	list ($ret, list($freshEntity)) = $this->loadEntities(array($entity->getId()));
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	/* Let entity do its post-load procedure */
	$ret = $freshEntity->onLoad();
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	return array(GalleryStatus::success(), $freshEntity);
    }

    /**
     * Acquire read locks on the given items
     *
     * @access public
     * @param int timeout before giving up on the lock
     * @return array object GalleryStatus a status code
     *               object a GalleryLock instance
     */
    function acquireReadLock($entityIds, $timeout) {
	/* It's ok to pass in a single id */
	if (!is_array($entityIds)) {
	    $entityIds = array($entityIds);
	}

	/* Acquire a non-transactional connection to use for this request */
	list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	/* Know when to call it quits */
	$cutoffTime = time() + $timeout;

	/* Get the true name of the lock table */
	list ($lockTable, $unused) = $this->_translateTableName('Lock');

	/*
	 * Algorithm:
	 * 1. Get clearance to acquire locks (and get the lock id)
	 * 2. If any of the entities that we want to lock are currently write
	 *    locked, then clear the request and go back to step 1.
	 * 3. Acquire our read locks
	 */
	while (true) {
	    list($ret, $lockId) = $this->_getLockClearance($cutoffTime);
	    if ($ret->isError()) {
		return array($ret->wrap(__FILE__, __LINE__), null);
	    }

	    /*
	     * Check to see if any of the ids that we care about are write
	     * locked.
	     */
	    $writeEntityIdCol = $this->_translateColumnName('writeEntityId');
	    $markers = GalleryUtilities::makeMarkers(sizeof($entityIds));
	    $query = 'SELECT COUNT(*) FROM ' . $lockTable . ' ' .
		    'WHERE ' . $writeEntityIdCol . ' IN (' . $markers . ') ';
	    $data = $entityIds;

	    $GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	    $this->_traceStart();
	    $recordSet = $db->Execute($query, $data);
	    $this->_traceStop();
	    if (!$recordSet) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }

	    $row = $recordSet->FetchRow();
	    if ($row[0] == 0 ) {
		/* Success */
		break;
	    } else {
		/* An entity that we want is write locked */
		$this->_releaseLockById($lockId);

		if (time() > $cutoffTime) {
		    return array(GalleryStatus::error(ERROR_LOCK_TIMEOUT, __FILE__, __LINE__),
				 null);
		}

		/* Wait a second and try again */
		sleep(1);

		/* Expire any bogus locks */
		$ret = $this->_expireLocks();
		if ($ret->isError()) {
		    return array($ret->wrap(__FILE__, __LINE__), null);
		}
	    }
	}

	/* Put in a read lock for every entity id */
	$lockIdCol = $this->_translateColumnName('lockId');
	$readEntityIdCol = $this->_translateColumnName('readEntityId');
	$freshUntilCol = $this->_translateColumnName('freshUntil');
	$freshUntil = time() + 30;
	foreach ($entityIds as $entityId) {
	    $query = sprintf('INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)',
			     $lockTable, $lockIdCol, $readEntityIdCol, $freshUntilCol);
	    $data = array($lockId, $entityId, $freshUntil);

	    $this->_traceStart();
	    $recordSet = $db->Execute($query, $data);
	    $this->_traceStop();
	    if (!$recordSet) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }
	}

	/* Drop the lock request, now that we've got the write locks */
	$requestCol = $this->_translateColumnName('request');
	$query = 'DELETE FROM ' . $lockTable . ' ' .
		'WHERE ' . $lockIdCol . '=? AND ' . $requestCol . '=1';
	$data = array($lockId);

	$this->_traceStart();
	$recordSet = $db->Execute($query, $data);
	$this->_traceStop();
	if (!$recordSet) {
	    $this->_releaseLockById($lockId);
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}

	return array(GalleryStatus::success(),
		     array($lockId, array('type' => LOCK_READ, 'ids' => $entityIds)));
    }

    /**
     * Acquire write locks on the given items
     *
     * @access public
     * @param array or integer a set of ids
     * @param int timeout before giving up on the lock
     * @return array object GalleryStatus a status code
     *               object a GalleryLock instance
     */
    function acquireWriteLock($entityIds, $timeout) {
	/* It's ok to pass in a single id */
	if (!is_array($entityIds)) {
	    $entityIds = array($entityIds);
	}

	/* Acquire a non-transactional connection to use for this request */
	list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	/* Know when to call it quits */
	$cutoffTime = time() + $timeout;

	/* Get the true name of the lock table */
	list ($lockTable, $unused) = $this->_translateTableName('Lock');

	/*
	 * Algorithm:
	 * 1. Get clearance to acquire locks (and get the lock id)
	 * 2. If any of the entities that we want to lock are currently locked,
	 *    then clear the request and go back to step 1.
	 * 3. Acquire our write locks
	 */
	while (true) {
	    list($ret, $lockId) = $this->_getLockClearance($cutoffTime);
	    if ($ret->isError()) {
		return array($ret->wrap(__FILE__, __LINE__), null);
	    }

	    /*
	     * Check to see if any of the ids that we care about are locked.
	     */
	    $readEntityIdCol = $this->_translateColumnName('readEntityId');
	    $writeEntityIdCol = $this->_translateColumnName('writeEntityId');
	    $markers = GalleryUtilities::makeMarkers(sizeof($entityIds));
	    $query = 'SELECT COUNT(*) FROM ' . $lockTable . ' ' .
		    'WHERE ' . $readEntityIdCol . ' IN (' . $markers . ') ' .
		    'OR ' . $writeEntityIdCol . ' IN (' . $markers . ')';
	    $data = $entityIds;
	    $data = array_merge($data, $entityIds);

	    $GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	    $this->_traceStart();
	    $recordSet = $db->Execute($query, $data);
	    $this->_traceStop();
	    if (!$recordSet) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }

	    $row = $recordSet->FetchRow();
	    if ($row[0] == 0 ) {
		/* Success */
		break;
	    } else {
		/* An entity that we want is still locked */
		$this->_releaseLockById($lockId);

		if (time() > $cutoffTime) {
		    return array(GalleryStatus::error(ERROR_LOCK_TIMEOUT, __FILE__, __LINE__),
				 null);
		}

		/* Wait a second and try again */
		sleep(1);

		/* Expire any bogus locks */
		$ret = $this->_expireLocks();
		if ($ret->isError()) {
		    return array($ret->wrap(__FILE__, __LINE__), null);
		}
	    }
	}

	/*
	 * We are approved to acquire our write locks.
	 */
	$lockIdCol = $this->_translateColumnName('lockId');
	$writeEntityIdCol = $this->_translateColumnName('writeEntityId');
	$freshUntilCol = $this->_translateColumnName('freshUntil');
	$freshUntil = time() + 30;
	foreach ($entityIds as $entityId) {
	    $query = sprintf('INSERT INTO %s (%s, %s, %s) VALUES (?, ?, ?)',
			     $lockTable, $lockIdCol, $writeEntityIdCol, $freshUntilCol);
	    $data = array($lockId, $entityId, $freshUntil);

	    $this->_traceStart();
	    $recordSet = $db->Execute($query, $data);
	    $this->_traceStop();
	    if (!$recordSet) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }
	}

	/* Drop the lock request, now that we've got the write locks */
	$requestCol = $this->_translateColumnName('request');
	$query = 'DELETE FROM ' . $lockTable . ' ' .
		'WHERE ' . $lockIdCol . '=? AND ' . $requestCol . '=1';
	$data = array($lockId);

	$this->_traceStart();
	$recordSet = $db->Execute($query, $data);
	$this->_traceStop();
	if (!$recordSet) {
	    $this->_releaseLockById($lockId);
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}

	return array(GalleryStatus::success(),
		     array($lockId, array('type' => LOCK_WRITE, 'ids' => $entityIds)));
    }

    /**
     * Refresh all the locks that we hold so that they aren't accidentally considered expired
     *
     * @param array the lock ids
     * @param int the new "fresh until" timestamp
     * @return object GalleryStatus a status code
     * @static
     */
    function refreshLocks($lockIds, $freshUntil) {
	if (!empty($lockIds)) {
	    /* Acquire a non-transactional connection to use for this request */
	    list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	    if ($ret->isError()) {
		return $ret->wrap(__FILE__, __LINE__);
	    }

	    list ($lockTable, $unused) = $this->_translateTableName('Lock');
	    $lockIdCol = $this->_translateColumnName('lockId');
	    $freshUntilCol = $this->_translateColumnName('freshUntil');
	    $lockIdMarkers = GalleryUtilities::makeMarkers(sizeof($lockIds));
	    $query = sprintf('UPDATE %s SET %s = ? WHERE %s in (%s)',
			     $lockTable, $freshUntilCol, $lockIdCol, $lockIdMarkers);

	    $this->_traceStart();
	    $data = array_merge(array($freshUntil), $lockIds);
	    $recordSet = $db->Execute($query, $data);
	    $this->_traceStop();

	    if (!$recordSet) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Delete all not-so-fresh locks.
     *
     * @return object GalleryStatus a status code
     * @static
     */
    function _expireLocks() {
	/* Acquire a non-transactional connection to use for this request */
	list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	list ($lockTable, $unused) = $this->_translateTableName('Lock');
	$freshUntilCol = $this->_translateColumnName('freshUntil');
	$query = sprintf('DELETE FROM %s WHERE %s < ?',
			 $lockTable, $freshUntilCol);

	$this->_traceStart();
	$recordSet = $db->Execute($query, array(time()));
	$this->_traceStop();

	if (!$recordSet) {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Internal function to release a lock by id.  Outsiders should use
     * releaseLock()
     *
     * @access private
     * @param int the lock id
     * @return object GalleryStatus a status code
     */
    function releaseLocks($lockIds) {
	$ret = $this->_releaseLockById($lockIds);
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Internal function to release a lock by id.  Outsiders should use
     * releaseLock()
     *
     * @access private
     * @param mixed the lock id, or an array of lock ids
     * @return object GalleryStatus a status code
     */
    function _releaseLockById($lockIds) {
	if (!is_array($lockIds)) {
	    $lockIds = array($lockIds);
	}

	/* Acquire a non-transactional connection to use for this request */
	list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/* Get the true name of the lock table */
	list ($lockTable, $unused) = $this->_translateTableName('Lock');

	$lockIdCol = $this->_translateColumnName('lockId');
	$markers = GalleryUtilities::makeMarkers(sizeof($lockIds));
	$query = 'DELETE FROM ' . $lockTable . ' ' .
		'WHERE ' . $lockIdCol . ' IN (' . $markers . ')';
	$data = $lockIds;

	$this->_traceStart();
	$recordSet = $db->Execute($query, $data);
	$this->_traceStop();
	if ($recordSet) {
	    return GalleryStatus::success();
	} else {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}
    }

    /**
     * Search the persistent store for the target values matching the given
     * criteria
     *
     * This is a flexible and powerful search mechanism.  You specify which
     * class members you wish to search for, how you want to search them, and
     * which class members you want returned in a very SQL like syntax
     *
     * @access public
     * @param array the search query
     * @param array any explicit data values required by the query
     * @return array object GalleryStatus a status code,
     *               array the result values
     */
    function search($query, $data=array(), $optional=array()) {
	if (empty($this->_db)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__), null);
	}

	$query = $this->_translateQuery($query);

	/* Run it with the right limits and return the results */
	$GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	if (isset($optional['limit']) && sizeof($optional['limit'])) {
	    if (empty($optional['limit']['count'])) {
		$count = -1;
	    } else {
		$count = $optional['limit']['count'];
	    }

	    if (empty($optional['limit']['offset'])) {
		$offset = -1;
	    } else {
		$offset = $optional['limit']['offset'];
	    }

	    $this->_traceStart();
	    $recordSet = $this->_db->SelectLimit($query, $count, $offset, $data);
	    $this->_traceStop();
	} else {
	    $this->_traceStart();
	    $recordSet = $this->_db->Execute($query, $data);
	    $this->_traceStop();
	}

	if ($recordSet) {
	    return array(GalleryStatus::success(),
			 new DatabaseSearchResults($recordSet));
	} else {
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}
    }

    /**
     * @see GalleryStorage.execute
     */
    function execute($statement, $data=array()) {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	$statement = $this->_translateQuery($statement);

	$this->_traceStart();
	$recordSet = $this->_db->Execute($statement, $data);
	$this->_traceStop();

	if ($recordSet) {
	    return GalleryStatus::success();
	} else {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}
    }

    /**
     * Add a new entry to a map
     *
     * @param object the map we're working on
     * @param array an associative array of data about the entry
     * @return object GalleryStatus a status code
     */
    function addMapEntry($mapName, $entry) {

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	$mapInfo = $this->_describeMapMembers($mapName);
	list ($tableName, $unused) = $this->_translateTableName($mapName);
	$data = array();
	$columns = array();
	foreach ($mapInfo['members'] as $memberName => $memberType) {
	    $columns[] = $this->_translateColumnName($memberName);
	    $value = $entry[$memberName];

	    if ($memberType & STORAGE_TYPE_BIT) {
		$value = $this->convertIntToBits($value);
	    } else {
		if ($memberType & STORAGE_TYPE_TIMESTAMP) {
		    $value = $this->_db->DBTimeStamp($value);
		}
	    }

	    $data[] = $value;
	}

	$markers = GalleryUtilities::makeMarkers(sizeof($columns));
	$query = 'INSERT INTO ' . $tableName . ' (';
	$query .= join(', ', $columns);
	$query .= ') VALUES (' . $markers . ')';

	$this->_traceStart();
	$recordSet = $this->_db->Execute($query, $data);
	$this->_traceStop();
	if ($recordSet === false) {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Remove an entry from a map
     *
     * This is dangerous, use very carefully!
     *
     * @param object the map we're working on
     * @param array an associative array of data about the entry
     * @return object GalleryStatus a status code
     */
    function removeMapEntry($mapName, $entry) {
	global $gallery;

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	$mapInfo = $this->_describeMapMembers($mapName);
	list ($tableName, $unused) = $this->_translateTableName($mapName);
	$data = array();
	$where = array();
	foreach ($mapInfo['members'] as $memberName => $memberType) {
	    if (isset($entry[$memberName])) {
		if (is_array($entry[$memberName])) {
		    $qs = array();
		    foreach ($entry[$memberName] as $value) {
			$qs[] = '?';
			$data[] = $value;
		    }
		    $where[] = $this->_translateColumnName($memberName) . ' IN ('
			     . implode(',', $qs) . ')';
		} else {
		    $where[] = $this->_translateColumnName($memberName) . '=?';
		    $data[] = $entry[$memberName];
		}
	    }
	}

	if (empty($where)) {
	    return GalleryStatus::error(ERROR_BAD_PARAMETER, __FILE__, __LINE__,
					'Missing where clause');
	}

	$query = 'DELETE FROM ' . $tableName . ' ';
	$query .= 'WHERE '  . join(' AND ', $where);

	$this->_traceStart();
	$recordSet = $this->_db->Execute($query, $data);
	$this->_traceStop();
	if (!$recordSet) {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Remove ALL entries from a map.
     *
     * This is dangerous, use very very carefully!
     *
     * @param object the map we're working on
     * @return object GalleryStatus a status code
     */
    function removeAllMapEntries($mapName) {
	list ($tableName, $unused) = $this->_translateTableName($mapName);
	$query = 'DELETE FROM ' . $tableName;

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	$this->_traceStart();
	$recordSet = $this->_db->Execute($query);
	$this->_traceStop();
	if ($recordSet === false) {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /**
     * Update an entry in this map
     *
     * @param array the entry to match
     * @param array the values to change
     * @return object GalleryStatus a status code
     */
    function updateMapEntry($mapName, $match, $change) {
	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	$mapInfo = $this->_describeMapMembers($mapName);
	list ($tableName, $unused) = $this->_translateTableName($mapName);
	$data = array();
	$set = array();
	$where = array();
	$wheredata = array();

	foreach ($mapInfo['members'] as $memberName => $memberType) {
	    if (array_key_exists($memberName, $match)) {
		if (GalleryUtilities::isA($match[$memberName], 'DatabaseSqlFragment')) {
		    $where[] = $this->_translateColumnName($memberName) . ' ' .
				$this->_translateQuery($match[$memberName]->getFragment());
		    foreach ($match[$memberName]->getValues() as $value) {
			$wheredata[] = $value;
		    }
		} else if (is_array($match[$memberName])) {
		    $qs = array();
		    foreach ($match[$memberName] as $value) {
			$qs[] = '?';
			if ($memberType & STORAGE_TYPE_BIT) {
			    $value = $this->convertIntToBits($value);
			}
			$wheredata[] = $value;
		    }
		    $where[] = $this->_translateColumnName($memberName) . ' IN ('
			     . implode(',', $qs) . ')';
		} else if (is_null($match[$memberName])) {
		    $where[] = $this->_translateColumnName($memberName) . ' IS NULL';
		} else {
		    $where[] = $this->_translateColumnName($memberName) . '=?';
		    $value = $match[$memberName];
		    if ($memberType & STORAGE_TYPE_BIT) {
			$value = $this->convertIntToBits($value);
		    }
		    $wheredata[] = $value;
		}
	    }

	    if (array_key_exists($memberName, $change)) {
		if (GalleryUtilities::isA($change[$memberName], 'DatabaseSqlFragment')) {
		    $set[] = $this->_translateColumnName($memberName) . ' ' .
				$this->_translateQuery($change[$memberName]->getFragment());
		    foreach ($change[$memberName]->getValues() as $value) {
			$setdata[] = $value;
		    }
		} else {
		    $set[] = $this->_translateColumnName($memberName) . '=?';
		    $value = $change[$memberName];
		    if ($memberType & STORAGE_TYPE_BIT) {
			$value = $this->convertIntToBits($value);
		    }
		    $setdata[] = $value;
		}
	    }
	}

	if (sizeof($set) == 0 || sizeof($where) == 0) {
	    return GalleryStatus::error(ERROR_BAD_PARAMETER, __FILE__, __LINE__);
	}

	$query = 'UPDATE ' . $tableName . ' ';
	$query .= 'SET ' . join(', ', $set) . ' ';
	$data = array_merge($data, $setdata);

	$query .= 'WHERE '  . join(' AND ', $where);
	$data = array_merge($data, $wheredata);

	$this->_traceStart();
	$recordSet = $this->_db->Execute($query, $data);
	$this->_traceStop();
	if (!$recordSet) {
	    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	}

	return GalleryStatus::success();
    }

    /*
     * Load up the table creation and alteration SQL files for the given module
     * @access private
     */
    function _getModuleSql($moduleId) {
	global $gallery;

	$platform = $gallery->getPlatform();
	$sqlDir = dirname(__FILE__) . '/../../../../modules/' . $moduleId .
	    '/classes/GalleryStorage/DatabaseStorage/schema/platform/' .
	    $this->getType();

	if (!$platform->file_exists($sqlDir)) {
	    return array(GalleryStatus::success(), array(), array(), array());
	}

	$tableDefinition = $tableAlter = $tableDelete = array();
	if ($dir = $platform->opendir($sqlDir)) {
	    while (($file = $platform->readdir($dir)) !== false) {
		/*
		 * There are three classes of files here, distinguished by name.
		 *   "Foo.sql" -- the definition for table "Foo"
		 *   "A_Foo_1.0.sql" -- an alteration to upgrade the 1.0 version of
		 *                      table "Foo" to the current version
		 *   "R_Foo_1.0.sql" -- delete table Foo.sql if it's currently at version 1.0
		 *
		 * We need to parse these file names and group them together
		 * such that once we figure out what version of the Foo table
		 * we have in the database we can figure out which file to
		 * apply to install/update it.
		 * In earlier versions (beta 3) we used the format R_Foo_1_0.sql), an underscore
		 * instead of a dot as the version separator. If a user has still these old files
		 * in his gallery2 dirs, they would be executed on each upgrade unless we accept
		 * here the old 1_0 and the new 1.0 format.
		 */
		if (preg_match('/^([AR])_(.*)_(\d+)[\._](\d+)\.sql$/', $file, $matches)) {
		    if ($matches[1] == 'R') {
			$tableDelete[$matches[2]][$matches[3]][$matches[4]] = $sqlDir . '/' . $file;
		    } else {
			$tableAlter[$matches[2]][$matches[3]][$matches[4]] = $sqlDir . '/' . $file;
		    }
		} else if (preg_match('/^(.*)\.sql$/', $file, $matches)) {
		    $tableDefinition[$matches[1]] = $sqlDir . '/' . $file;
		}
	    }
	    $platform->closedir($dir);
	} else {
	    return array(GalleryStatus::error(ERROR_BAD_PATH, __FILE__, __LINE__),
			 null, null, null);
	}

	return array(GalleryStatus::success(), $tableDefinition, $tableAlter, $tableDelete);
    }

    /**
     * Install or update the database schema for the given module
     *
     * @return object GalleryStatus a status code
     */
    function configureStore($moduleId) {
	global $gallery;
	$gallery->guaranteeTimeLimit(20);

	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	list ($ret, $tableDefinition, $tableAlter, $tableDelete) = $this->_getModuleSql($moduleId);
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/* Get the metabase info about this database */
	$this->_traceStart();
	$metatables = $this->_db->MetaTables();
	$this->_traceStop();

	/*
	 * Some databases (notably MySQL on Win32) don't support mixed case
	 * table names.  So, when we get the meta table list back, it's lower
	 * case.  Force all metatable listings to lower case and then expect
	 * them to be lowercase so that we're consistent.
	 */
	for ($i = 0; $i < sizeof($metatables); $i++) {
	    $metatables[$i] = strtolower($metatables[$i]);
	}

	/* Do the schema table first */
	list ($schemaTableName, $unused) = $this->_translateTableName('Schema');
	if (!in_array(strtolower($schemaTableName), $metatables)) {
	    $ret = $this->_executeSqlFile($tableDefinition['Schema']);
	    if ($ret->isError()) {
		return $ret->wrap(__FILE__, __LINE__);
	    }
	    unset($tableDefinition['Schema']);

	    /* Create our sequences now */
	    foreach (array(DATABASE_SEQUENCE_LOCK, DATABASE_SEQUENCE_ID) as $sequenceId) {
		$this->_traceStart();
		$recordSet = $this->_db->CreateSequence($this->_tablePrefix . $sequenceId);
		$this->_traceStop();
		if (empty($recordSet)) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }
	}

	/* Load all table versions */
	list ($ret, $tableVersions) = $this->_loadTableVersions();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/*
	 * Now take care of the rest of the tables.  If the table doesn't
	 * exist, apply the current table definition.  If it already exists,
	 * check to see if there is an upgrade available for the given table
	 * version.  If so, apply it.
	 */
	foreach ($tableDefinition as $rawTableName => $sqlFile) {
	    $gallery->guaranteeTimeLimit(20);
	    list ($tableName, $unused, $tableNameInSchema) =
		$this->_translateTableName($rawTableName);
	    if (!in_array(strtolower($tableName), $metatables)) {
		$ret = $this->_executeSqlFile($sqlFile);
		if ($ret->isError()) {
		    return $ret->wrap(__FILE__, __LINE__);
		}
	    } else {
		while (1) {
		    /* The table exists -- see if we have an upgrade for it */
		    if (empty($tableVersions[$tableNameInSchema])) {
			/*
			 * We've found a SQL file that matches a table in the
			 * database, but has no matching version info in the
			 * schema table.  How can this be?  Leave it alone.
			 */
			if ($gallery->getDebug()) {
			    $gallery->debug("Table $rawTableName: missing entry in Schema table");
			}
			break;
		    }

		    /* If we locate an appropriate upgrade, apply it. */
		    list ($major,  $minor) = $tableVersions[$tableNameInSchema];
		    if (!empty($tableAlter[$rawTableName][$major][$minor])) {
			$sqlFile = $tableAlter[$rawTableName][$major][$minor];
			$ret = $this->_executeSqlFile($sqlFile);
			if ($ret->isError()) {
			    return $ret->wrap(__FILE__, __LINE__);
			}

			/* Reload all table versions, cause one has now changed */
			list ($ret, $tableVersions) = $this->_loadTableVersions();
			if ($ret->isError()) {
			    return $ret->wrap(__FILE__, __LINE__);
			}
		    } else {
			/* No upgrade available */
			break;
		    }
		}
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Perform any cleanup necessary after installing or upgrading the given module.
     *
     * @return object GalleryStatus a status code
     */
    function configureStoreCleanup($moduleId) {
	global $gallery;

	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	list ($ret, $tableDefinition, $tableAlter, $tableDelete) = $this->_getModuleSql($moduleId);
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/* Get the metabase info about this database */
	$this->_traceStart();
	$metatables = $this->_db->MetaTables();
	$this->_traceStop();

	/*
	 * Some databases (notably MySQL on Win32) don't support mixed case
	 * table names.  So, when we get the meta table list back, it's lower
	 * case.  Force all metatable listings to lower case and then expect
	 * them to be lowercase so that we're consistent.
	 */
	for ($i = 0; $i < sizeof($metatables); $i++) {
	    $metatables[$i] = strtolower($metatables[$i]);
	}

	/* Load all table versions */
	list ($ret, $tableVersions) = $this->_loadTableVersions();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/* Now locate any existing tables that should be deleted and drop them. */
	foreach (array_keys($tableDelete) as $rawTableName) {
	    if ($rawTableName == 'Schema') {
		continue;
	    }

	    list ($tableName, $unused, $tableNameInSchema) =
		$this->_translateTableName($rawTableName);
	    if (in_array(strtolower($tableName), $metatables)) {
		/* The table exists -- see if we should delete it */
		if (empty($tableVersions[$tableNameInSchema])) {
		    /*
		     * We've found a SQL file that matches a table in the
		     * database, but has no matching version info in the
		     * schema table.  How can this be?  Leave it alone.
		     */
		    if ($gallery->getDebug()) {
			$gallery->debug("Table $rawTableName: missing entry in Schema table");
		    }
		} else {
		    $gallery->guaranteeTimeLimit(20);
		    list ($major,  $minor) = $tableVersions[$tableNameInSchema];
		    if (!empty($tableDelete[$rawTableName][$major][$minor])) {
			$ret = $this->_executeSqlFile($tableDelete[$rawTableName][$major][$minor]);
			if ($ret->isError()) {
			    return $ret->wrap(__FILE__, __LINE__);
			}
		    }
		}
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Uninstall the database schema for the given module
     *
     * @return object GalleryStatus a status code
     */
    function unconfigureStore($moduleId) {
	global $gallery;

	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	list ($ret, $tableDefinition, $tableAlter, $tableDelete) = $this->_getModuleSql($moduleId);
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	/* Get the metabase info about this database */
	$this->_traceStart();
	$metatables = $this->_db->MetaTables();
	$this->_traceStop();

	/*
	 * Some databases (notably MySQL on Win32) don't support mixed case
	 * table names.  So, when we get the meta table list back, it's lower
	 * case.  Force all metatable listings to lower case and then expect
	 * them to be lowercase so that we're consistent.
	 */
	for ($i = 0; $i < sizeof($metatables); $i++) {
	    $metatables[$i] = strtolower($metatables[$i]);
	}

	/*
	 * Now take care of the rest of the tables.  If the table doesn't
	 * exist, apply the current table definition.  If it already exists,
	 * check to see if there is an upgrade available for the given table
	 * version.  If so, apply it.
	 */
	list ($schemaTableName, $unused) = $this->_translateTableName('Schema');
	$schemaColumnName = $this->_translateColumnName('name');
	foreach ($tableDefinition as $rawTableName => $sqlFile) {
	    /* Don't drop the schema table, it's part of the core. */
	    if ($rawTableName == 'Schema') {
		continue;
	    }

	    $this->_traceStart();
	    list ($tableName, $unused, $tableNameInSchema) =
		$this->_translateTableName($rawTableName);
	    if (in_array(strtolower($tableName), $metatables)) {
		/* Drop the table and yank it from the schema table */
		$dropQuery = sprintf('DROP TABLE %s', $tableName);
		$recordSet = $this->_db->Execute($dropQuery);
		if (empty($recordSet)) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }

	    $cleanQuery = sprintf('DELETE FROM %s where %s=?',
				  $schemaTableName, $schemaColumnName);
	    $recordSet = $this->_db->Execute($cleanQuery, array($tableNameInSchema));
	    if (empty($recordSet)) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	    $this->_traceStop();
	}

	return GalleryStatus::success();
    }

    /**
     * Examine the schema table and return the version of all the Gallery tables
     *
     * @return array object GalleryStatus a status code
     *               array (name => (major, minor))
     */
    function _loadTableVersions() {
	global $gallery;

	$GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	$this->_traceStart();
	list ($schemaTableName, $unused) = $this->_translateTableName('Schema');
	$recordSet = $this->_db->Execute('SELECT ' .
					 $this->_translateColumnName('name') . ', ' .
					 $this->_translateColumnName('major') . ', ' .
					 $this->_translateColumnName('minor') .
					 ' FROM ' .
					 $schemaTableName);
	$this->_traceStop();

	if (empty($recordSet)) {
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__,
					      'Error reading schema table'), null);
	}

	$tableVersions = array();
	while ($row = $recordSet->FetchRow()) {
	    $tableVersions[$row[0]] = array($row[1], $row[2]);
	}

	return array(GalleryStatus::success(), $tableVersions);
    }

    /**
     * Execute a given SQL file against the database.  Prefix table and column
     * names as necessary.  Split multiple commands in the file into separate
     * Execute() calls.
     *
     * @return object GalleryStatus a status code
     */
    function _executeSqlFile($fileName) {
	global $gallery;
	$platform = $gallery->getPlatform();

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	if (($buffer = $platform->file_get_contents($fileName)) === false) {
	    return GalleryStatus::error(ERROR_BAD_PATH, __FILE__, __LINE__,
					"Unable to read file $fileName");
	}

	/*
	 * Split the file where semicolons are followed by a blank line..
	 * PL/SQL blocks will have other semicolons, so we can't split on every one.
	 */
	foreach (preg_split('/; *\r?\n *\r?\n/s', $buffer) as $query) {
	    $query = trim($query);
	    if (!empty($query)) {
		$query = str_replace('DB_TABLE_PREFIX', $this->_tablePrefix, $query);
		$query = str_replace('DB_COLUMN_PREFIX', $this->_columnPrefix, $query);

		/* Perform database specific replacements */
		foreach ($this->getSqlReplacements() as $key => $value) {
		    $query = str_replace($key, $value, $query);
		}

		$this->_traceStart();
		$recordSet = $this->_db->Execute($query);
		$this->_traceStop();
		if (empty($recordSet)) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__,
						"Error trying to load $fileName");
		}
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Clean out and reset the persistent store for this strategy.
     *
     * @return object GalleryStatus a status code
     */
    function cleanStore() {
	global $gallery;

	$ret = $this->_guaranteeTransaction();
	if ($ret->isError()) {
	    return $ret;
	}

	/* Get the metabase info about this database */
	$this->_traceStart();
	$metatables = $this->_db->MetaTables();
	$this->_traceStop();

	/*
	 * Some databases (notably MySQL on Win32) don't support mixed case
	 * table names.  So, when we get the meta table list back, it's lower
	 * case.  Force all metatable listings to lower case and then expect
	 * them to be lowercase so that we're consistent.
	 */
	for ($i = 0; $i < sizeof($metatables); $i++) {
	    $metatables[$i] = strtolower($metatables[$i]);
	}

	/* If the schema table exists then delete all the tables it lists */
	list ($schemaTableName, $unused) = $this->_translateTableName('Schema');
	if (in_array(strtolower($schemaTableName), $metatables)) {
	    /* Load all table versions */
	    list ($ret, $tableVersions) = $this->_loadTableVersions();
	    if ($ret->isError()) {
		return $ret->wrap(__FILE__, __LINE__);
	    }

	    foreach (array_keys($tableVersions) as $rawTableName) {
		list ($tableName, $unused) = $this->_translateTableName($rawTableName);
		$query = sprintf('DROP TABLE %s', $tableName);

		$this->_traceStart();
		$recordSet = $this->_db->Execute($query);
		$this->_traceStop();
		if (empty($recordSet)) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }

	    /* Get rid of our sequences */
	    foreach (array(DATABASE_SEQUENCE_LOCK, DATABASE_SEQUENCE_ID)
		     as $sequenceId) {

		$this->_traceStart();
		$recordSet = $this->_db->DropSequence($this->_tablePrefix . $sequenceId);
		$this->_traceStop();
		if (empty($recordSet)) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Begin a new transaction, if the storage layer supports them.
     *
     * @return object GalleryStatus a status code
     */
    function beginTransaction() {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	if ($this->_isTransactional) {
	    $this->_traceStart();
	    $ok = $this->_db->BeginTrans();
	    $this->_traceStop();

	    if (!$ok) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * @see GalleryStorage::commitTransaction
     */
    function commitTransaction() {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	if ($this->_isTransactional && $this->_db->transCnt > 0) {
	    $this->_traceStart();
	    $ok = $this->_db->CommitTrans();
	    $this->_traceStop();

	    if (!$ok) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Guarantee that the connection has an existing transaction open,
     * and if not, open a new one
     *
     * @return object GalleryStatus
     */
    function _guaranteeTransaction() {
	if ($this->_isTransactional && !$this->_db->transCnt) {
	    $ret = $this->beginTransaction();
	    if ($ret->isError()) {
		return $ret;
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * @see GalleryStorage::rollbackTransaction
     */
    function rollbackTransaction() {
	if (empty($this->_db)) {
	    return GalleryStatus::error(ERROR_STORAGE_CONNECTION, __FILE__, __LINE__);
	}

	if ($this->_isTransactional && $this->_db->transCnt > 0) {
	    $this->_traceStart();
	    $ok = $this->_db->RollbackTrans();
	    $this->_traceStop();

	    if (!$ok) {
		return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Database specific string replacements in our SQL files.
     *
     * @return array(key => value, key => value) where we'll replace key with
     *         value in the SQL text.
     */
    function getSqlReplacements() {
	return array();
    }

    /**
     * Return storage profiling information in HTML format
     *
     * @return string HTML
     */
    function getProfilingHtml() {
	$this->_traceStart();
	 $perf =& NewPerfMonitor($this->_db);
	 $buf = $perf->SuspiciousSQL();
	 $buf .= $perf->ExpensiveSQL();
	$this->_traceStop();
	 return $buf;
    }

    /**
     * Return true if at least some set of G2 tables is already installed.
     * We use the existence of the schema table as our indicator.
     * @return array object GalleryStatus a status code
     *               boolean true if the tables are installed
     */
    function isInstalled() {
	/* Get the metabase info about this database */
	$this->_traceStart();
	$metatables = $this->_db->MetaTables();
	$this->_traceStop();

	list ($schemaTableName, $unused) = $this->_translateTableName('Schema');
	$isInstalled = preg_match("/\b$schemaTableName\b/i", join(' ', $metatables));
	return array(GalleryStatus::success(), $isInstalled);
    }

    /**
     * Optimize the back end.
     *
     * @return object GalleryStatus a status code
     */
    function optimize() {
	/* Load all table versions */
	list ($ret, $tableVersions) = $this->_loadTableVersions();
	if ($ret->isError()) {
	    return $ret->wrap(__FILE__, __LINE__);
	}

	$statement = $this->getOptimizeStatement();
	if (!empty($statement)) {
	    foreach (array_keys($tableVersions) as $tableName) {
		$query = sprintf($statement, $this->_tablePrefix . $tableName);
		$this->_traceStart();
		$recordSet = $this->_db->Execute($query);
		$this->_traceStop();

		if (!$recordSet) {
		    return GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__);
		}
	    }
	}

	return GalleryStatus::success();
    }

    /**
     * Return the database specific fragment of SQL to optimize a table.
     *
     * @return string a SQL statement with an embedded %s for the table name
     */
    function getOptimizeStatement() {
	return null;
    }

    /**
     * Identify the type of entity associated with the id provided
     *
     * @param int a object id
     * @return array a GalleryStatus and a string class name
     */
    function _identifyEntities($ids) {
	assert('!empty($ids)');

	if (!is_array($ids)) {
	    $ids = array($ids);
	    $returnArray = false;
	} else {
	    $returnArray = true;
	}

	$checkIds = array();
	foreach ($ids as $id) {
	    if (!GalleryDataCache::containsKey("DatabaseStorage::_identifyEntities($id)")) {
		$checkIds[] = $id;
	    }
	}

	$local = array();
	if (!empty($checkIds)) {
	    $idCol = $this->_translateColumnName('id');
	    $entityTypeCol = $this->_translateColumnName('entityType');
	    list ($table, $unused) = $this->_translateTableName('GalleryEntity');
	    $markers = GalleryUtilities::makeMarkers(sizeof($checkIds));
	    $query = 'SELECT ' . $idCol . ', ' . $entityTypeCol .
		    ' FROM ' . $table .
		    ' WHERE ' . $idCol . ' IN (' . $markers . ')';

	    $GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;

	    $this->_traceStart();
	    $recordSet = $this->_db->Execute($query, $checkIds);
	    $this->_traceStop();

	    if ($recordSet) {
		while ($row = $recordSet->FetchRow()) {
		    if (empty($row[1])) {
			return array(GalleryStatus::error(ERROR_MISSING_OBJECT, __FILE__, __LINE__),
				     null);
		    } else {
			/*
			 * Save a copy locally, in case the global cache is disabled
			 * (like in the upgrader)
			 */
			$local[$row[0]] = $row[1];
			GalleryDataCache::put("DatabaseStorage::_identifyEntities($row[0])",
					      $row[1], true);
		    }
		}
	    } else {
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }
	}

	if ($returnArray) {
	    $results = array();
	    foreach ($ids as $id) {
		if (isset($local[$id])) {
		    $results[] = $local[$id];
		} else if (GalleryDataCache::containsKey(
			       "DatabaseStorage::_identifyEntities($id)")) {
		    $results[] = GalleryDataCache::get("DatabaseStorage::_identifyEntities($id)");
		} else {
		    return array(GalleryStatus::error(ERROR_MISSING_OBJECT, __FILE__, __LINE__,
						      "Missing object for $id"), null);
		}
	    }
	} else {
	    $results = GalleryDataCache::get("DatabaseStorage::_identifyEntities($ids[0])");
	}

	return array(GalleryStatus::success(), $results);
    }

    /**
     * Translate all table and column names from the [Entity::member] notation to
     * table.column notation.
     *
     * @param string the raw query
     * @param string the translated query
     */
    function _translateQuery($query) {
	/* Change '[Class::member]' to 'table.column' or 'alias.column' */
	while (ereg('\[([[:alnum:]_=]*)::([[:alnum:]_]*)\]', $query, $regs)) {
	    $class = $regs[1];
	    $member = $regs[2];
	    list ($table, $alias) = $this->_translateTableName($class);

	    $column = $this->_translateColumnName($member);
	    if ($alias) {
		$query = str_replace("[${class}::${member}]", "$alias.$column", $query);
	    } else if ($class) {
		$query = str_replace("[${class}::${member}]", "$table.$column", $query);
	    } else {
		$query = str_replace("[::${member}]", "$column", $query);
	    }
	}

	/* Change '[Class]' to 'table' */
	while (ereg('\[([[:alnum:]_=]*)\]', $query, $regs)) {
	    $class = $regs[1];
	    list ($table, $alias) = $this->_translateTableName($class);
	    if ($alias == null) {
		$query = str_replace("[${class}]", "$table", $query);
	    } else {
		list ($ret, $as) = $this->getFunctionSql('AS', array());
		if ($ret->isError()) {
		    /* TODO: propagate this back up as a GalleryStatus */
		    return 'QUERY ERROR';
		}
		$query = str_replace("[${class}]", "$table $as $alias", $query);
	    }
	}

	return $query;
    }

    /**
     * Translate a potentially unsafe column name into a safe one
     *
     * @param string the name of a column
     * @return string a safe column name
     * @access private
     */
    function _translateColumnName($columnName) {
	return $this->_columnPrefix . $columnName;
    }

    /**
     * Translate a potentially unsafe table name into a safe one by adding
     * a prefix or suffix to avoid conflicting with a reserved word.
     *
     * eg:
     * Comment   => array(g2_Comment, null, Comment)
     * Comment=1 => array(g2_Comment, C0, Comment)
     *
     * @param string the name of a table
     * @return array string a safe table name
     *               an alias for this table
     *               the unsafe, but translated, table name
     * @access private
     */
    function _translateTableName($tableName) {

	/*
	 * Remove the the ubiquitous "Gallery" prefix, since it's not part of
	 * the schema name.  For now we automatically translate the class name
	 * into the schema name by doing this.  If this ever becomes a problem,
	 * we should start hand-writing the schema name instead and then
	 * pushing that into the interface classes so that we don't have to
	 * automatically generate the schema name (and get it wrong).
	 */
	$tableName = str_replace('Gallery', '', $tableName);

	/*
	 * Other abbreviations to keep table names under Oracle's 30 character limit.
	 */
	$tableName = str_replace('Preferences', 'Prefs', $tableName);
	$tableName = str_replace('Toolkit', 'Tk', $tableName);
	$tableName = str_replace('TkOperation', 'TkOperatn', $tableName);

	/*
	 * Deal with aliases, which will be in the form of "table=1", "table=2",
	 * etc.  Translate "1" into "A", "2" into "B", etc.
	 */
	$split = explode('=', $tableName);
	$alias = '';
	if (sizeof($split) > 1) {
	    list ($tableName, $number) = $split;
	    for ($i = 0; $i < strlen($tableName); $i++) {
		$chr = $tableName[$i];
		if ($chr >= 'A' && $chr <= 'Z') {
		    $alias .= $chr;
		}
	    }
	    $alias = strtolower($alias) . ($number - 1);
	} else {
	    $tableName = $split[0];
	    $alias = null;
	}

	return array($this->_tablePrefix . $tableName, $alias, $tableName);
    }

    /**
     * Describe all the members of a entity
     *
     * @param string a class name
     * @access protected
     * @return array object GalleryStatus a status code
     *               member => info associative array
     */
    function _describeEntityMembers($entityName) {
	global $gallery;

	if (empty($this->_entityInfoCache[$entityName])) {
	    if (class_exists($entityName)) {
		$entity = new $entityName();
	    } else {
		list ($ret, $entity) =
		    GalleryCoreApi::newFactoryInstance('GalleryEntity', $entityName);
		if ($ret->isError()) {
		    return array($ret->wrap(__FILE__, __LINE__), null);
		}
	    }
	    if (!isset($entity)) {
		return array(GalleryStatus::error(ERROR_MISSING_OBJECT, __FILE__, __LINE__,
						  $entityName), null);
	    }
	    $this->_entityInfoCache[$entityName] = $entity->getPersistentMemberInfo();
	}

	return array(GalleryStatus::success(),
		     $this->_entityInfoCache[$entityName]);
    }

    /**
     * Describe all the members of a map
     *
     * @param string a class name
     * @return array member name => member type
     * @access protected
     */
    function _describeMapMembers($mapName) {

	if (empty($this->_mapInfoCache[$mapName])) {
	    eval("\$info = $mapName::getMapInfo();");
	    $this->_mapInfoCache[$mapName] = $info;
	}

	return $this->_mapInfoCache[$mapName];
    }

    /**
     * Start tracing
     *
     * This method is for use by GalleryStorage subclasses only.  If Gallery is
     * in debug, this method will begin storing all output and routing it into
     * Gallery's debug system
     *
     * @access protected
     */
    function _traceStart() {
	global $gallery;
	if ($gallery->getDebug()) {
	    ob_start();
	}
    }

    /**
     * Stop tracing
     *
     * This method is for use by GalleryStorage subclasses only.  If Gallery is
     * in debug, this will method will stop tracing.
     */
    function _traceStop() {
	global $gallery;
	if ($gallery->getDebug()) {
	    $buf = ob_get_contents();
	    ob_end_clean();
	    $gallery->debug($buf);
	}
    }

    /**
     * Internal function to get clearance to acquire locks
     *
     * Request clearance to acquire locks and then wait until it's our turn.
     *
     * @param int the time to stop trying to get clearance
     * @return object GalleryStatus a status code
     */
    function _getLockClearance($cutoffTime) {
	/* Get the true name of the lock table */
	list ($lockTable, $unused) = $this->_translateTableName('Lock');

	/* Acquire a non-transactional connection to use for this request */
	list ($ret, $db) = $this->_getNonTransactionalDatabaseConnection();
	if ($ret->isError()) {
	    return array($ret->wrap(__FILE__, __LINE__), null);
	}

	/* Get a new lock id */
	$this->_traceStart();
	$lockId = $db->GenId($this->_tablePrefix . DATABASE_SEQUENCE_LOCK);
	$this->_traceStop();

	/* Put in a lock request */
	$lockIdCol = $this->_translateColumnName('lockId');
	$requestCol = $this->_translateColumnName('request');
	$freshUntilCol = $this->_translateColumnName('freshUntil');
	$query = sprintf('INSERT INTO %s (%s, %s, %s) VALUES(?, 1, ?)',
			 $lockTable, $lockIdCol, $requestCol, $freshUntilCol);
	$data = array($lockId, time() + 30);

	$this->_traceStart();
	$recordSet = $db->Execute($query, $data);
	$this->_traceStop();
	if (!$recordSet) {
	    $this->_releaseLockById($lockId);
	    return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	}

	/* Wait till it's our turn */
	while (true) {
	    $query = 'SELECT ' . $lockIdCol . ' FROM ' . $lockTable . ' ' .
		    'WHERE ' . $requestCol . '=1 ORDER BY ' .
		    $lockIdCol . ' ASC';

	    $GLOBALS['ADODB_FETCH_MODE'] = ADODB_FETCH_NUM;
	    $this->_traceStart();
	    $recordSet = $db->SelectLimit($query, 1);
	    $this->_traceStop();
	    if (!$recordSet) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_STORAGE_FAILURE, __FILE__, __LINE__), null);
	    }

	    $row = $recordSet->FetchRow();
	    if ($row[0] == $lockId) {
		break;
	    }

	    /* Wait a second and try again */
	    sleep(1);

	    /* Expire any bogus locks */
	    $ret = $this->_expireLocks();
	    if ($ret->isError()) {
		return array($ret->wrap(__FILE__, __LINE__), null);
	    }

	    if (time() > $cutoffTime) {
		$this->_releaseLockById($lockId);
		return array(GalleryStatus::error(ERROR_LOCK_TIMEOUT, __FILE__, __LINE__), null);
	    }
	}

	return array(GalleryStatus::success(), $lockId);
    }
}
?>
