<?PHP
#
#   FILE:  ItemFactory.php
#
#   Part of the ScoutLib application support library
#   Copyright 2007-2016 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#

/**
* Common factory class for item manipulation.  Not intended to be used
* directly, but rather as a parent for factory classes for specific
* item types.  For a derived class to use the temp methods the item
* record in the database must include a "DateLastModified" column, and
* the item object must include a "Delete()" method.
*/
abstract class ItemFactory
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /**
    * Class constructor.
    * @param string $ItemClassName Class of items to be manipulated by factory.
    * @param string $ItemTableName Name of database table used to store info
    *       about items.
    * @param string $ItemIdFieldName Name of field in database table used to
    *       store item IDs.
    * @param string $ItemNameFieldName Name of field in database table used to
    *       store item names.  (OPTIONAL)
    * @param bool $OrderOpsAllowed If TRUE, ordering operations are allowed
    *       with items, and the database table must contain "Previous" and
    *       "Next" fields as described by the PersistentDoublyLinkedList class.
    * @param string $SqlCondition SQL condition clause (without "WHERE") to
    *       include when retrieving items from database.  (OPTIONAL)
    */
    public function __construct($ItemClassName, $ItemTableName, $ItemIdFieldName,
            $ItemNameFieldName = NULL, $OrderOpsAllowed = FALSE, $SqlCondition = NULL)
    {
        # save item access names
        $this->ItemClassName = $ItemClassName;
        $this->ItemTableName = $ItemTableName;
        $this->ItemIdFieldName = $ItemIdFieldName;
        $this->ItemNameFieldName = $ItemNameFieldName;

        # save flag indicating whether item type allows ordering operations
        $this->OrderOpsAllowed = $OrderOpsAllowed;
        if ($OrderOpsAllowed)
        {
            $this->OrderList = new PersistentDoublyLinkedList(
                    $ItemTableName, $ItemIdFieldName);
            $this->SetOrderOpsCondition(NULL);
        }

        # save database operation conditional
        $this->SqlCondition = $SqlCondition;

        # grab our own database handle
        $this->DB = new Database();
    }

    /**
    * Get class name of items manipulated by factory.
    * @return string Class name.
    */
    public function GetItemClassName()
    {
        return $this->ItemClassName;
    }

    /**
    * Clear out (call the Delete() method) for any temp items more than specified
    *       number of minutes old.
    * @param int $MinutesUntilStale Number of minutes before items are considered stale.
    *       (OPTIONAL - defaults to 7 days)
    * @return Number of stale items deleted.
    */
    public function CleanOutStaleTempItems($MinutesUntilStale = 10080)
    {
        # load array of stale items
        $MinutesUntilStale = max($MinutesUntilStale, 1);
        $this->DB->Query("SELECT ".$this->ItemIdFieldName." FROM ".$this->ItemTableName
                   ." WHERE ".$this->ItemIdFieldName." < 0"
                   ." AND DateLastModified < DATE_SUB(NOW(), "
                            ." INTERVAL ".intval($MinutesUntilStale)." MINUTE)"
                   .($this->SqlCondition ? " AND ".$this->SqlCondition : ""));
        $ItemIds = $this->DB->FetchColumn($this->ItemIdFieldName);

        # delete stale items
        foreach ($ItemIds as $ItemId)
        {
            $Item = new $this->ItemClassName($ItemId);
            $Item->Delete();
        }

        # report number of items deleted to caller
        return count($ItemIds);
    }

    /**
    * Retrieve next available (non-temp) item ID.  If there are currently no
    * items, an ID of 1 will be returned.
    * @return int Item ID.
    */
    public function GetNextItemId()
    {
        # if no highest item ID found
        $HighestItemId = $this->GetHighestItemId(TRUE);
        if ($HighestItemId <= 0)
        {
            # start with item ID 1
            $ItemId = 1;
        }
        else
        {
            # else use next ID available after highest
            $ItemId = $HighestItemId + 1;
        }

        # return next ID to caller
        return $ItemId;
    }

    /**
    * Retrieve highest item ID in use.
    * @param bool $IgnoreSqlCondition If TRUE, any SQL condition set via the
    *       constructor is ignored.  (OPTIONAL, defaults to FALSE)
    * @return int Item ID.
    * @see ItemFactory::ItemFactory()
    */
    public function GetHighestItemId($IgnoreSqlCondition = FALSE)
    {
        # use class-wide condition if set
        $ConditionString = ($this->SqlCondition && !$IgnoreSqlCondition)
                ? " WHERE ".$this->SqlCondition : "";

        # return highest item ID to caller
        return $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                    ." FROM ".$this->ItemTableName
                                    .$ConditionString
                                    ." ORDER BY ".$this->ItemIdFieldName
                                    ." DESC LIMIT 1",
                                $this->ItemIdFieldName);
    }

    /**
    * Return next available temporary item ID.
    * @return int Temporary item ID.
    */
    public function GetNextTempItemId()
    {
        $LowestItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName
                        ." FROM ".$this->ItemTableName
                        ." ORDER BY ".$this->ItemIdFieldName
                        ." ASC LIMIT 1",
                $this->ItemIdFieldName);
        if ($LowestItemId > 0)
        {
            $ItemId = -1;
        }
        else
        {
            $ItemId = $LowestItemId - 1;
        }
        return $ItemId;
    }

    /**
    * Get count of items.
    * @param string $Condition SQL condition to include in query to retrieve
    *       item count.  The condition should not include "WHERE".  (OPTIONAL)
    * @param bool $IncludeTempItems Whether to include temporary items in
    *       count.  (OPTIONAL, defaults to FALSE)
    * @return int Item count.
    */
    public function GetItemCount($Condition = NULL, $IncludeTempItems = FALSE)
    {
        # use condition if supplied
        $ConditionString = ($Condition != NULL) ? " WHERE ".$Condition : "";

        # if temp items are to be excluded
        if (!$IncludeTempItems)
        {
            # if a condition was previously set
            if (strlen($ConditionString))
            {
                # add in condition to exclude temp items
                $ConditionString .= " AND (".$this->ItemIdFieldName." >= 0)";
            }
            else
            {
                # use condition to exclude temp items
                $ConditionString = " WHERE ".$this->ItemIdFieldName." >= 0";
            }
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # retrieve item count
        $Count = $this->DB->Query("SELECT COUNT(*) AS RecordCount"
                                      ." FROM ".$this->ItemTableName
                                      .$ConditionString,
                                  "RecordCount");

        # return count to caller
        return intval($Count);
    }

    /**
    * Return array of item IDs.
    * @param string $Condition SQL condition clause to restrict selection
    *       of items (should not include "WHERE").
    * @param bool $IncludeTempItems Whether to include temporary items
    *       in returned set.  (OPTIONAL, defaults to FALSE)
    * @param string $SortField Database column to use to sort results.
    *       (OPTIONAL)
    * @param bool $SortAscending If TRUE, sort items in ascending order,
    *       otherwise sort items in descending order.  (OPTIONAL, and
    *       only meaningful if a sort field is specified.)
    * @return array Array of item IDs.
    */
    public function GetItemIds($Condition = NULL, $IncludeTempItems = FALSE,
            $SortField = NULL, $SortAscending = TRUE)
    {
        # if temp items are supposed to be included
        if ($IncludeTempItems)
        {
            # condition is only as supplied
            $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;
        }
        else
        {
            # condition is non-negative IDs plus supplied condition
            $ConditionString = " WHERE ".$this->ItemIdFieldName." >= 0"
                       .(($Condition == NULL) ? "" : " AND ".$Condition);
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # add sorting if specified
        if ($SortField !== NULL)
        {
            $ConditionString .= " ORDER BY `".addslashes($SortField)."` "
                    .($SortAscending ? "ASC" : "DESC");
        }

        # get item IDs
        $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                      ." FROM ".$this->ItemTableName
                                      .$ConditionString);
        $ItemIds = $this->DB->FetchColumn($this->ItemIdFieldName);

        # return IDs to caller
        return $ItemIds;
    }

    /**
    * Get newest modification date (based on values in "DateLastModified"
    * column in database table).
    * @param string $Condition SQL condition clause to restrict selection
    *       of items (should not include "WHERE").
    * @return string Lastest modification date in SQL date/time format.
    */
    public function GetLatestModificationDate($Condition = NULL)
    {
        # set up SQL condition if supplied
        $ConditionString = ($Condition == NULL) ? "" : " WHERE ".$Condition;

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            if (strlen($ConditionString))
            {
                $ConditionString .= " AND ".$this->SqlCondition;
            }
            else
            {
                $ConditionString = " WHERE ".$this->SqlCondition;
            }
        }

        # return modification date for item most recently changed
        return $this->DB->Query("SELECT MAX(DateLastModified) AS LastChangeDate"
                                    ." FROM ".$this->ItemTableName.$ConditionString,
                                "LastChangeDate");
    }

    /**
    * Retrieve item by item ID.  This method assumes that an item can be
    * loaded by passing an item ID to the appropriate class constructor.
    * @param int $ItemId Item ID.
    * @return object Item of appropriate class.
    */
    public function GetItem($ItemId)
    {
        return new $this->ItemClassName($ItemId);
    }

    /**
    * Check that item exists with specified ID.
    * @param int $ItemId ID of item.
    * @param bool $IgnoreSqlCondition If TRUE, any SQL condition set in the
    *       constructor is ignored.  (OPTIONAL, defaults to FALSE)
    * @return bool TRUE if item exists with specified ID.
    */
    public function ItemExists($ItemId, $IgnoreSqlCondition = FALSE)
    {
        if (!is_numeric($ItemId))
        {
            return FALSE;
        }
        $Condition = $IgnoreSqlCondition ? ""
                : ($this->SqlCondition ? " AND ".$this->SqlCondition : "");
        $ItemCount = $this->DB->Query("SELECT COUNT(*) AS ItemCount"
                ." FROM ".$this->ItemTableName
                ." WHERE ".$this->ItemIdFieldName." = ".intval($ItemId)
                .$Condition, "ItemCount");
        return ($ItemCount > 0) ? TRUE : FALSE;
    }

    /**
    * Retrieve item by name.
    * @param string $Name Name to match.
    * @param bool $IgnoreCase If TRUE, ignore case when attempting to match
    *       the item name.  (OPTIONAL, defaults to FALSE)
    * @return object Item of appropriate class or NULL if item not found.
    */
    public function GetItemByName($Name, $IgnoreCase = FALSE)
    {
        # get item ID
        $ItemId = $this->GetItemIdByName($Name, $IgnoreCase);

        # if item not found
        if ($ItemId === FALSE)
        {
            # report error to caller
            return NULL;
        }
        else
        {
            # load object and return to caller
            return $this->GetItem($ItemId);
        }
    }

    /**
    * Retrieve item ID by name.
    * @param string $Name Name to match.
    * @param bool $IgnoreCase If TRUE, ignore case when attempting to match
    *       the item name.  (OPTIONAL, defaults to FALSE)
    * @return int ID or FALSE if name not found.
    * @throws Exception If item type does not have a name field defined.
    */
    public function GetItemIdByName($Name, $IgnoreCase = FALSE)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            throw new Exception("Attempt to get item ID by name on item type"
                    ."(".$this->ItemClassName.") that has no name field specified.");
        }

        # if caching is off or item ID is already loaded
        if (($this->CachingEnabled != TRUE)
                || !isset($this->ItemIdByNameCache[$this->SqlCondition][$Name]))
        {
            # query database for item ID
            $Comparison = $IgnoreCase
                    ? "LOWER(".$this->ItemNameFieldName.") = '"
                            .addslashes(strtolower($Name))."'"
                    : $this->ItemNameFieldName." = '" .addslashes($Name)."'";
            $ItemId = $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                          ." FROM ".$this->ItemTableName
                                          ." WHERE ".$Comparison
                                                .($this->SqlCondition
                                                        ? " AND ".$this->SqlCondition
                                                        : ""),
                                       $this->ItemIdFieldName);
            $this->ItemIdByNameCache[$this->SqlCondition][$Name] =
                    ($this->DB->NumRowsSelected() == 0) ? FALSE : $ItemId;
        }

        # return ID or error indicator to caller
        return $this->ItemIdByNameCache[$this->SqlCondition][$Name];
    }

    /**
    * Retrieve item names.
    * @param string $SqlCondition SQL condition (w/o "WHERE") for name
    *      retrieval. (OPTIONAL)
    * @return array Array with item names as values and item IDs as indexes.
    */
    public function GetItemNames($SqlCondition = NULL)
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            throw new Exception("Attempt to get array of item names"
                    ." on item type (".$this->ItemClassName.") that has no"
                    ." name field specified.");
        }

        # query database for item names
        $Condition = "";
        if ($SqlCondition)
        {
            $Condition = "WHERE ".$SqlCondition;
        }
        if ($this->SqlCondition)
        {
            if (strlen($Condition))
            {
                $Condition .= " AND ".$this->SqlCondition;
            }
            else
            {
                $Condition = " WHERE ".$this->SqlCondition;
            }
        }
        $this->DB->Query("SELECT ".$this->ItemIdFieldName
                                            .", ".$this->ItemNameFieldName
                                        ." FROM ".$this->ItemTableName." "
                                        .$Condition
                                        ." ORDER BY ".$this->ItemNameFieldName);
        $Names = $this->DB->FetchColumn(
                $this->ItemNameFieldName, $this->ItemIdFieldName);

        # return item names to caller
        return $Names;
    }

    /**
    * Retrieve items.
    * @param string $SqlCondition SQL condition (w/o "WHERE") for name
    *       retrieval. (OPTIONAL)
    * @return Array with item objects as values and item IDs as indexes.
    */
    public function GetItems($SqlCondition = NULL)
    {
        $Items = array();
        $Ids = $this->GetItemIds($SqlCondition);
        foreach ($Ids as $Id)
        {
            $Items[$Id] = $this->GetItem($Id);
        }
        return $Items;
    }

    /**
    * Retrieve items of specified type as HTML option list with item names
    * as labels and item IDs as value attributes.  The first element on the list
    * will have a label of "--" and an ID of -1 to indicate no item selected.
    * @param string $OptionListName Value of option list "name" attribute.
    * @param int $SelectedItemId ID of currently-selected item or array of IDs
    *       of currently-selected items.  (OPTIONAL)
    * @param string $SqlCondition SQL condition (w/o "WHERE") for item retrieval.
    *       (OPTIONAL, defaults to NULL)
    * @param int $DisplaySize Display length of option list.  (OPTIONAL,
    *       defaults to 1)
    * @param bool $SubmitOnChange Whether to submit form when option list changes.
    *       (OPTIONAL, defaults to FALSE)
    * @param bool $Disabled If TRUE, field will not be editable.
    * @return HTML for option list.
    */
    public function GetItemsAsOptionList(
            $OptionListName, $SelectedItemId = NULL, $SqlCondition = NULL,
            $DisplaySize = 1, $SubmitOnChange = FALSE, $Disabled = FALSE)
    {
        # retrieve requested fields
        $ItemNames = $this->GetItemNames($SqlCondition);

        # create option list
        $OptList = new HtmlOptionList(
                $OptionListName, $ItemNames, $SelectedItemId);

        # set list attributes
        $OptList->SubmitOnChange($SubmitOnChange);
        $OptList->Size($DisplaySize);
        $OptList->Disabled($Disabled);

        # return generated HTML for list to caller
        return $OptList->GetHtml();
    }

    /**
    * Check whether item name is currently in use.
    * @param string $Name Name to check.
    * @param bool $IgnoreCase If TRUE, ignore case when checking.  (Defaults to FALSE)
    * @return TRUE if name is in use, otherwise FALSE.
    */
    public function NameIsInUse($Name, $IgnoreCase = FALSE)
    {
        $Condition = $IgnoreCase
                ? "LOWER(".$this->ItemNameFieldName.")"
                        ." = '".addslashes(strtolower($Name))."'"
                : $this->ItemNameFieldName." = '".addslashes($Name)."'";
        if ($this->SqlCondition)
        {
            $Condition .= " AND ".$this->SqlCondition;
        }
        $NameCount = $this->DB->Query("SELECT COUNT(*) AS RecordCount FROM "
                .$this->ItemTableName." WHERE ".$Condition, "RecordCount");
        return ($NameCount > 0) ? TRUE : FALSE;
    }

    /**
    * Retrieve items with names matching search string.
    * @param string $SearchString String to search for.
    * @param int $NumberOfResults Number of results to return.  (OPTIONAL,
    *       defaults to 100)
    * @param bool $IncludeVariants SHould Variants be included?
    *       (NOT YET IMPLEMENTED)  (OPTIONAL)
    * @param bool $UseBooleanMode If TRUE, perform search using MySQL
    *       "Boolean Mode", which among other things supports inclusion and
    *       exclusion operators on terms in the search string.  (OPTIONAL,
    *       defaults to TRUE)
    * @param int $Offset Beginning offset into results.  (OPTIONAL, defaults
    *       to 0, which is the first element)
    * @param array $IdExclusions List of IDs of items to exclude.
    * @param array $ValueExclusions List of names of items to exclude.
    * @return array List of item names, with item IDs for index.
    */
    public function SearchForItemNames($SearchString, $NumberOfResults = 100,
            $IncludeVariants = FALSE, $UseBooleanMode = TRUE, $Offset=0,
            $IdExclusions = array(), $ValueExclusions=array())
    {
        # error out if this is an illegal operation for this item type
        if ($this->ItemNameFieldName == NULL)
        {
            throw new Exception("Attempt to search for item names on item type"
                    ."(".$this->ItemClassName.") that has no name field specified.");
        }

        # return no results if empty search string passed in
        if (!strlen(trim($SearchString))) {  return array();  }

        # construct SQL query
        $DB = new Database();
        $QueryString = "SELECT ".$this->ItemIdFieldName.",".$this->ItemNameFieldName
                ." FROM ".$this->ItemTableName." WHERE "
                .$this->ConstructSqlConditionsForSearch(
                    $SearchString, $IncludeVariants, $UseBooleanMode, $IdExclusions,
                    $ValueExclusions) ;

        # limit response set
        $QueryString .= " LIMIT ".intval($NumberOfResults)." OFFSET "
            .intval($Offset);

        # perform query and retrieve names and IDs of items found by query
        $DB->Query($QueryString);
        $Names = $DB->FetchColumn($this->ItemNameFieldName, $this->ItemIdFieldName);

        # remove excluded words that were shorter than the MinWordLength
        # (these will always be returned as mysql effectively ignores them)
        $MinWordLen = $DB->Query(
            "SHOW VARIABLES WHERE variable_name='ft_min_word_len'", "Value");

        # explode the search string into whitespace delimited tokens,
        # iterate over each token
        $Words = preg_split("/[\s]+/", trim($SearchString));
        foreach ($Words as $Word)
        {
            # if this token was an exclusion
            if ($Word[0] == "-")
            {
                # remove the - prefix to get the TgtWord
                $TgtWord = substr($Word, 1);

                # if this token was an exclusion shorter than MindWordLen
                if (strlen($TgtWord) < $MinWordLen)
                {
                    # filter names that match this exclusion from results
                    $NewNames = array();
                    foreach ($Names as $Id => $Name)
                    {
                        if (! preg_match('/\b'.$TgtWord.'/i', $Name))
                        {
                            $NewNames[$Id] = $Name;
                        }
                    }
                    $Names = $NewNames;
                }
            }
        }

        # return names to caller
        return $Names;
    }

    /**
    * Retrieve count of items with names matching search string.
    * @param string $SearchString String to search for.
    * @param bool $IncludeVariants Include Variants ?
    *       (NOT YET IMPLEMENTED)  (OPTIONAL)
    * @param bool $UseBooleanMode If TRUE, perform search using MySQL
    *       "Boolean Mode", which among other things supports inclusion and
    *       exclusion operators on terms in the search string.  (OPTIONAL,
    *       defaults to TRUE)
    * @param array $IdExclusions List of IDs of items to exclude.
    * @param array $ValueExclusions List of names of items to exclude.
    * @return int Count of matching items.
    */
    public function GetCountForItemNames($SearchString, $IncludeVariants = FALSE,
            $UseBooleanMode = TRUE, $IdExclusions = array(), $ValueExclusions=array())
    {
        # return no results if empty search string passed in
        if (!strlen(trim($SearchString))) {  return 0;  }

        # construct SQL query
        $DB = new Database();
        $QueryString = "SELECT COUNT(*) as ItemCount FROM "
            .$this->ItemTableName." WHERE "
            .$this->ConstructSqlConditionsForSearch(
                $SearchString, $IncludeVariants, $UseBooleanMode, $IdExclusions,
                $ValueExclusions);

        # perform query and retrieve names and IDs of items found by query
        $DB->Query($QueryString);
        return intval($DB->FetchField("ItemCount"));
    }

    /**
    * Enable/disable caching of item information.
    * @param bool $NewValue TRUE to enable caching, or FALSE to disable. (OPTIONAL)
    * @return bool TRUE if caching is enabled, otherwise FALSE.
    * @see ClearCaches()
    */
    public function CachingEnabled($NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            $this->CachingEnabled = $NewValue ? TRUE : FALSE;
        }
        return $this->CachingEnabled;
    }

    /**
    * Clear item information caches.
    * @see CachingEnabled()
    */
    public function ClearCaches()
    {
        unset($this->ItemIdByNameCache);
    }


    # ---- Ordering Operations -----------------------------------------------

    /**
    * Set SQL condition (added to WHERE clause) used to select items for
    * ordering operations.  NULL may be passed in to clear any existing
    * condition.
    * @param string $Condition SQL condition (should not include "WHERE").
    */
    public function SetOrderOpsCondition($Condition)
    {
        # condition is non-negative IDs (non-temp items) plus supplied condition
        $NewCondition = $this->ItemIdFieldName." >= 0"
                   .($Condition ? " AND ".$Condition : "")
                   .($this->SqlCondition ? " AND ".$this->SqlCondition : "");
        $this->OrderList->SqlCondition($NewCondition);
    }

    /**
    * Insert item into order before specified item.  If the item is already
    * present in the order, it is moved to the new location.  If the target
    * item is not found, the new item is added to the beginning of the order.
    * @param mixed $TargetItem Item (object or ID) to insert before.
    * @param mixed $NewItem Item (object or ID) to insert.
    */
    public function InsertBefore($TargetItem, $NewItem)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # insert/move item
        $this->OrderList->InsertBefore($TargetItem, $NewItem);
    }

    /**
    * Insert item into order after specified item.  If the item is already
    * present in the order, it is moved to the new location.  If the target
    * item is not found, the new item is added to the end of the order.
    * @param mixed $TargetItem Item (object or ID) to insert after.
    * @param mixed $NewItem Item to insert.
    */
    public function InsertAfter($TargetItem, $NewItem)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # insert/move item
        $this->OrderList->InsertAfter($TargetItem, $NewItem);
    }

    /**
    * Add item to beginning of order.  If the item is already present in the order,
    * it is moved to the beginning.
    * @param mixed $Item Item (object or ID) to add.
    */
    public function Prepend($Item)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # prepend item
        $this->OrderList->Prepend($Item);
    }

    /**
    * Add item to end of order.  If the item is already present in the order,
    * it is moved to the end.
    * @param mixed $Item Item (object or ID) to add.
    */
    public function Append($Item)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # add/move item
        $this->OrderList->Append($Item);
    }

    /**
    * Retrieve list of item IDs in order.
    * @return array List of item IDs.
    */
    public function GetItemIdsInOrder()
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # retrieve list of IDs
        return $this->OrderList->GetIds();
    }

    /**
    * Remove item from existing order.  If the item is not currently in the
    * existing order, then the call has no effect.  This does not delete or
    * otherwise remove the item from the database.
    * @param int $ItemId ID of item to be removed from order.
    */
    public function RemoveItemFromOrder($ItemId)
    {
        # error out if ordering operations are not allowed for this item type
        if (!$this->OrderOpsAllowed)
        {
            throw new Exception("Attempt to perform order operation on item"
                    ." type (".$this->ItemClassName.") that does not support"
                    ." ordering.");
        }

        # remove item
        $this->OrderList->Remove($ItemId);
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    /**
    * Construct an SQL query string to search against item names.
    * @param string $SearchString String to search for.
    * @param bool $IncludeVariants Include Variants ?
    *       (NOT YET IMPLEMENTED)  (OPTIONAL)
    * @param bool $UseBooleanMode If TRUE, perform search using MySQL
    *       "Boolean Mode", which among other things supports inclusion and
    *       exclusion operators on terms in the search string.  (OPTIONAL,
    *       defaults to TRUE)
    * @param array $IdExclusions List of IDs of items to exclude.
    * @param array $ValueExclusions List of names of items to exclude.
    * @see ItemFactory::SearchForItemNames()
    * @see ItemFactory::GetCountForItemNames()
    * @return String of SQL conditions.
    */
    private function ConstructSqlConditionsForSearch(
        $SearchString, $IncludeVariants = FALSE,
        $UseBooleanMode = TRUE, $IdExclusions = array(), $ValueExclusions=array() )
    {
        $QueryString = "";

        # If the search string is valid but shorter than the minimum word length
        # indexed by the FTS, just do a normal equality test instead of using
        # the index. Otherwise, FTS away.
        $DB = new Database();
        $MinWordLen = $DB->Query(
            "SHOW VARIABLES WHERE variable_name='ft_min_word_len'", "Value");
        if (strlen($SearchString) < $MinWordLen)
        {
            $QueryString .= " ".$this->ItemNameFieldName
                    ."='".addslashes($SearchString)."'";
        }
        else if ($UseBooleanMode)
        {
            # When we're in boolean mode, construct a search string to use in our
            # query. Include quoted strings verbatim.  Make sure that each
            # non-quoted word is prefixed with either + or -, so that it is
            # either explicitly included or explicitily excluded.
            # Keep track of stopwords in the search query (these will not
            # match in the boolean search because FTS indexes ignores them).
            # Append 'REGEXP' queries to match, so that our search results
            # pay *some* attention to stopwords.
            $SearchString = preg_replace("/[)\(><]+/", "", $SearchString);
            $Words = preg_split("/[\s]+/", trim($SearchString));
            $NewSearchString = "";
            $SearchedStopwords = array();
            $InQuotedString = FALSE;
            $SqlVarObj = new MysqlSystemVariables($DB);
            $StopWordList = $SqlVarObj->GetStopWords();
            $MinWordLen = $SqlVarObj->Get("ft_min_word_len");
            foreach ($Words as $Word)
            {
                # remove any query-specific terms, punctuation, etc.
                $JustTheWord = preg_replace("/[^a-zA-Z-]/", "", $Word);

                # require (boolean AND) certain words
                if ($InQuotedString == FALSE
                    && !in_array($JustTheWord, $StopWordList)
                    && strlen($JustTheWord) >= $MinWordLen
                    && $Word{0} != "+"
                    && $Word{0} != "-")
                {
                    $NewSearchString .= "+";
                }

                if (preg_match("/^\"/", $Word)) {  $InQuotedString = TRUE;  }
                if (preg_match("/\"$/", $Word)) {  $InQuotedString = FALSE;  }
                $NewSearchString .= $Word." ";

                if (in_array($JustTheWord, $StopWordList))
                {
                    $SearchedStopwords[]= $JustTheWord;
                }
            }

            # Build onto our query string by appending the boolean search
            # conditions
            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($NewSearchString))."'"
                    ." IN BOOLEAN MODE)";

            # If there were any stopwords included in the search string,
            #  append REGEXP conditions to match those.
            foreach ($SearchedStopwords as $Stopword)
            {
                $QueryString .= " AND ".$this->ItemNameFieldName
                    ." REGEXP '".addslashes(preg_quote($Stopword))."'";
            }
        }
        else
        {
            # If we weren't in boolean mode, just include the search
            # string verbatim as a match condition:
            $QueryString .= " MATCH (".$this->ItemNameFieldName.")"
                    ." AGAINST ('".addslashes(trim($SearchString))."')";
        }

        # add each ID exclusion
        foreach ($IdExclusions as $IdExclusion)
        {
            $QueryString .= " AND ".$this->ItemIdFieldName." != '"
                .addslashes($IdExclusion)."' ";
        }

        # add each value exclusion
        foreach ($ValueExclusions as $ValueExclusion)
        {
            $QueryString .= " AND ".$this->ItemNameFieldName." != '"
                .addslashes($ValueExclusion)."' ";
        }

        # add class-wide condition if set
        if ($this->SqlCondition)
        {
            $QueryString .= " AND ".$this->SqlCondition;
        }

        return $QueryString;
    }

    protected $DB;

    private $CachingEnabled = TRUE;
    private $ItemClassName;
    private $ItemTableName;
    private $ItemIdByNameCache;
    private $ItemIdFieldName;
    private $ItemNameFieldName;
    private $OrderOpsAllowed;
    private $OrderList;
    private $SqlCondition;
}
