<category name="general.system.image">
<parent>general.system</parent>
</category>
+ <category name="general.system.search">
+ <parent>general.system</parent>
+ </category>
<category name="general.system.date">
<parent>general.system</parent>
</category>
</option>
<!-- /general.system.image -->
+ <!-- general.system.search -->
+ <option name="search_engine">
+ <categoryname>general.system.search</categoryname>
+ <optiontype>radioButton</optiontype>
+ <defaultvalue><![CDATA[mysql]]></defaultvalue>
+ <selectoptions><![CDATA[mysql:wcf.acp.option.search_engine.mysql]]></selectoptions>
+ </option>
+ <!-- /general.system.search -->
+
<!-- general.system.cookie -->
<option name="cookie_prefix">
<categoryname>general.system.cookie</categoryname>
*/
protected function finalize() {
// create search index tables
- SearchIndexManager::createSearchIndexTables();
+ SearchIndexManager::getInstance()->createSearchIndices();
CacheHandler::getInstance()->flushAll();
}
use wcf\data\search\Search;
use wcf\data\search\SearchAction;
use wcf\system\application\ApplicationHandler;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\IllegalLinkException;
use wcf\system\exception\NamedUserException;
use wcf\system\exception\PermissionDeniedException;
*/
public $searchIndexCondition = null;
+ /**
+ * class name for $searchIndexCondition object
+ * @var string
+ */
+ public $searchIndexConditionClassName = 'wcf\system\database\util\PreparedStatementConditionBuilder';
+
/**
* search hash to modify existing search
* @var string
// default conditions
$userIDs = $this->getUserIDs();
- $this->searchIndexCondition = new PreparedStatementConditionBuilder(false);
+ $this->searchIndexCondition = new $this->searchIndexConditionClassName(false);
// user ids
if (!empty($userIDs)) {
if ($parsedAttachmentID) $attachmentIDs[] = $parsedAttachmentID;
}
- $attachmentList = new AttachmentList();
- $attachmentList->getConditionBuilder()->add("attachment.attachmentID IN (?)", array($attachmentIDs));
- $attachmentList->readObjectIDs();
- return $attachmentList->getObjectIDs();
+ if (!empty($attachmentIDs)) {
+ $attachmentList = new AttachmentList();
+ $attachmentList->getConditionBuilder()->add("attachment.attachmentID IN (?)", array($attachmentIDs));
+ $attachmentList->readObjectIDs();
+
+ return $attachmentList->getObjectIDs();
+ }
}
return false;
--- /dev/null
+<?php
+namespace wcf\system\search;
+use wcf\system\SingletonFactory;
+
+/**
+ * Default implementation for search engines, this class should be extended by
+ * all search engines to preserve compatibility in case of interface changes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+abstract class AbstractSearchEngine extends SingletonFactory implements ISearchEngine { }
--- /dev/null
+<?php
+namespace wcf\system\search;
+use wcf\data\object\type\ObjectType;
+use wcf\data\object\type\ObjectTypeList;
+use wcf\system\SingletonFactory;
+use wcf\system\WCF;
+
+/**
+ * Default implementation for search index managers, this class should be extended by
+ * all search index managers to preserve compatibility in case of interface changes.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+abstract class AbstractSearchIndexManager extends SingletonFactory implements ISearchIndexManager {
+ /**
+ * @see \wcf\system\search\ISearchIndexManager::createSearchIndices()
+ */
+ public function createSearchIndices() {
+ // get definition id
+ $sql = "SELECT definitionID
+ FROM wcf".WCF_N."_object_type_definition
+ WHERE definitionName = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute(array('com.woltlab.wcf.searchableObjectType'));
+ $row = $statement->fetchArray();
+
+ $objectTypeList = new ObjectTypeList();
+ $objectTypeList->getConditionBuilder()->add("object_type.definitionID = ?", array($row['definitionID']));
+ $objectTypeList->readObjects();
+
+ foreach ($objectTypeList as $objectType) {
+ $this->createSearchIndex($objectType);
+ }
+ }
+
+ /**
+ * Creates the search index for given object type. Returns true if the
+ * index was created, otherwise false.
+ *
+ * @param \wcf\data\object\type\ObjectType $objectType
+ * @return boolean
+ */
+ abstract protected function createSearchIndex(ObjectType $objectType);
+}
--- /dev/null
+<?php
+namespace wcf\system\search;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+
+/**
+ * Default interface for search engines.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+interface ISearchEngine {
+ /**
+ * Searches for the given string and returns the data of the found messages.
+ *
+ * @param string $q
+ * @param array $objectTypes
+ * @param boolean $subjectOnly
+ * @param \wcf\system\database\util\PreparedStatementConditionBuilder $searchIndexCondition
+ * @param array $additionalConditions
+ * @param string $orderBy
+ * @param integer $limit
+ * @return array
+ */
+ public function search($q, array $objectTypes, $subjectOnly = false, PreparedStatementConditionBuilder $searchIndexCondition = null, array $additionalConditions = array(), $orderBy = 'time DESC', $limit = 1000);
+}
--- /dev/null
+<?php
+namespace wcf\system\search;
+
+/**
+ * Default interface for search index managers.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+interface ISearchIndexManager {
+ /**
+ * Adds a new entry.
+ *
+ * @param string $objectType
+ * @param integer $objectID
+ * @param string $message
+ * @param string $subject
+ * @param integer $time
+ * @param integer $userID
+ * @param string $username
+ * @param integer $languageID
+ * @param string $metaData
+ * @param array<mixed> $additionalData
+ */
+ public function add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array());
+
+ /**
+ * Updates the search index.
+ *
+ * @param string $objectType
+ * @param integer $objectID
+ * @param string $message
+ * @param string $subject
+ * @param integer $time
+ * @param integer $userID
+ * @param string $username
+ * @param integer $languageID
+ * @param string $metaData
+ * @param array<mixed> $additionalData
+ */
+ public function update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array());
+
+ /**
+ * Deletes search index entries.
+ *
+ * @param string $objectType
+ * @param array<integer> $objectIDs
+ */
+ public function delete($objectType, array $objectIDs);
+
+ /**
+ * Resets the search index.
+ *
+ * @param string $objectType
+ */
+ public function reset($objectType);
+
+ /**
+ * Creates the search index for all searchable objects.
+ */
+ public function createSearchIndices();
+}
namespace wcf\system\search;
use wcf\data\object\type\ObjectTypeCache;
use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\database\DatabaseException;
-use wcf\system\exception\SystemException;
use wcf\system\SingletonFactory;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
/**
* SearchEngine searches for given query in the selected object types.
* @subpackage system.search
* @category Community Framework
*/
-class SearchEngine extends SingletonFactory {
+class SearchEngine extends SingletonFactory implements ISearchEngine {
/**
* list of available object types
* @var array
protected $availableObjectTypes = array();
/**
- * MySQL's minimum word length for fulltext indices
- * @var integer
+ * search engine object
+ * @var \wcf\system\search\ISearchEngine
*/
- protected static $ftMinWordLen = null;
+ protected $searchEngine = null;
/**
* @see \wcf\system\SingletonFactory::init()
}
/**
- * Searches for the given string and returns the data of the found messages.
+ * Returns the search engine object.
*
- * @param string $q
- * @param array $objectTypes
- * @param boolean $subjectOnly
- * @param \wcf\system\database\util\PreparedStatementConditionBuilder $searchIndexCondition
- * @param array $additionalConditions
- * @param string $orderBy
- * @param integer $limit
- * @return array
+ * @return \wcf\system\search\ISearchEngine
*/
- public function search($q, array $objectTypes, $subjectOnly = false, PreparedStatementConditionBuilder $searchIndexCondition = null, array $additionalConditions = array(), $orderBy = 'time DESC', $limit = 1000) {
- // handle sql types
- $fulltextCondition = null;
- $relevanceCalc = '';
- if (!empty($q)) {
- $q = $this->parseSearchQuery($q);
-
- $fulltextCondition = new PreparedStatementConditionBuilder(false);
- switch (WCF::getDB()->getDBType()) {
- case 'wcf\system\database\MySQLDatabase':
- $fulltextCondition->add("MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST (? IN BOOLEAN MODE)", array($q));
- break;
-
- case 'wcf\system\database\PostgreSQLDatabase':
- // replace * with :*
- $q = str_replace('*', ':*', $q);
-
- $fulltextCondition->add("fulltextIndex".($subjectOnly ? "SubjectOnly" : '')." @@ to_tsquery(?)", array($q));
- break;
-
- default:
- throw new SystemException("your database type doesn't support fulltext search");
- }
-
- if ($orderBy == 'relevance ASC' || $orderBy == 'relevance DESC') {
- switch (WCF::getDB()->getDBType()) {
- case 'wcf\system\database\MySQLDatabase':
- $relevanceCalc = "MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST ('".escapeString($q)."') + (5 / (1 + POW(LN(1 + (".TIME_NOW." - time) / 2592000), 2))) AS relevance";
- break;
-
- case 'wcf\system\database\PostgreSQLDatabase':
- $relevanceCalc = "ts_rank_cd(fulltextIndex".($subjectOnly ? "SubjectOnly" : '').", '".escapeString($q)."') AS relevance";
- break;
+ protected function getSearchEngine() {
+ if ($this->searchEngine === null) {
+ $className = '';
+ if (SEARCH_ENGINE != 'mysql') {
+ $className = 'wcf\system\search\\'.SEARCH_ENGINE.'\\'.ucfirst(SEARCH_ENGINE).'SearchEngine';
+ if (!class_exists($className)) {
+ $className = '';
}
}
- }
-
- // build search query
- $sql = '';
- $parameters = array();
- foreach ($objectTypes as $objectTypeName) {
- $objectType = $this->getObjectType($objectTypeName);
- if (!empty($sql)) $sql .= "\nUNION\n";
- $additionalConditionsConditionBuilder = (isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : null);
- if (($specialSQL = $objectType->getSpecialSQLQuery($fulltextCondition, $searchIndexCondition, $additionalConditionsConditionBuilder, $orderBy))) {
- $sql .= "(".$specialSQL.")";
- }
- else {
- $sql .= "(
- SELECT ".$objectType->getIDFieldName()." AS objectID,
- ".$objectType->getSubjectFieldName()." AS subject,
- ".$objectType->getTimeFieldName()." AS time,
- ".$objectType->getUsernameFieldName()." AS username,
- '".$objectTypeName."' AS objectType
- ".($relevanceCalc ? ',search_index.relevance' : '')."
- FROM ".$objectType->getTableName()."
- INNER JOIN (
- SELECT objectID
- ".($relevanceCalc ? ','.$relevanceCalc : '')."
- FROM ".SearchIndexManager::getTableName($objectTypeName)."
- WHERE ".($fulltextCondition !== null ? $fulltextCondition : '')."
- ".(($searchIndexCondition !== null && $searchIndexCondition->__toString()) ? ($fulltextCondition !== null ? "AND " : '').$searchIndexCondition : '')."
- ".(!empty($orderBy) && $fulltextCondition === null ? 'ORDER BY '.$orderBy : '')."
- LIMIT 1000
- ) search_index
- ON (".$objectType->getIDFieldName()." = search_index.objectID)
- ".$objectType->getJoins()."
- ".(isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : '')."
- )";
- }
-
- if ($fulltextCondition !== null) $parameters = array_merge($parameters, $fulltextCondition->getParameters());
- if ($searchIndexCondition !== null) $parameters = array_merge($parameters, $searchIndexCondition->getParameters());
- if (isset($additionalConditions[$objectTypeName])) $parameters = array_merge($parameters, $additionalConditions[$objectTypeName]->getParameters());
- }
- if (empty($sql)) {
- throw new SystemException('no object types given');
- }
-
- if (!empty($orderBy)) {
- $sql .= " ORDER BY " . $orderBy;
- }
-
- // send search query
- $messages = array();
- $statement = WCF::getDB()->prepareStatement($sql, $limit);
- $statement->execute($parameters);
- while ($row = $statement->fetchArray()) {
- $messages[] = array(
- 'objectID' => $row['objectID'],
- 'objectType' => $row['objectType']
- );
- }
-
- return $messages;
- }
-
- /**
- * Manipulates the search term (< and > used as quotation marks):
- *
- * - <test foo> becomes <+test* +foo*>
- * - <test -foo bar> becomes <+test* -foo* +bar*>
- * - <test "foo bar"> becomes <+test* +"foo bar">
- *
- * @see http://dev.mysql.com/doc/refman/5.5/en/fulltext-boolean.html
- *
- * @param string $query
- */
- protected function parseSearchQuery($query) {
- $query = StringUtil::trim($query);
-
- // expand search terms with a * unless they're encapsulated with quotes
- $inQuotes = false;
- $previousChar = $tmp = '';
- $controlCharacterOrSpace = false;
- $chars = array('+', '-', '*');
- $ftMinWordLen = self::getFulltextMinimumWordLength();
- for ($i = 0, $length = mb_strlen($query); $i < $length; $i++) {
- $char = mb_substr($query, $i, 1);
-
- if ($inQuotes) {
- if ($char == '"') {
- $inQuotes = false;
- }
- }
- else {
- if ($char == '"') {
- $inQuotes = true;
- }
- else {
- if ($char == ' ' && !$controlCharacterOrSpace) {
- $controlCharacterOrSpace = true;
- $tmp .= '*';
- }
- else if (in_array($char, $chars)) {
- $controlCharacterOrSpace = true;
- }
- else {
- $controlCharacterOrSpace = false;
- }
- }
- }
-
- /*
- * prepend a plus sign (logical AND) if ALL these conditions are given:
- *
- * 1) previous character:
- * - is empty (start of string)
- * - is a space (MySQL uses spaces to separate words)
- *
- * 2) not within quotation marks
- *
- * 3) current char:
- * - is NOT +, - or *
- */
- if (($previousChar == '' || $previousChar == ' ') && !$inQuotes && !in_array($char, $chars)) {
- // check if the term is shorter than MySQL's ft_min_word_len
- if ($i + $ftMinWordLen <= $length) {
- $term = '';// $char;
- for ($j = $i, $innerLength = $ftMinWordLen + $i; $j < $innerLength; $j++) {
- $currentChar = mb_substr($query, $j, 1);
- if ($currentChar == '"' || $currentChar == ' ' || in_array($currentChar, $chars)) {
- break;
- }
-
- $term .= $currentChar;
- }
-
- if (mb_strlen($term) == $ftMinWordLen) {
- $tmp .= '+';
- }
- }
+ // fallback to MySQL
+ if (empty($className)) {
+ $className = 'wcf\system\search\mysql\MysqlSearchEngine';
}
-
- $tmp .= $char;
- $previousChar = $char;
- }
-
- // handle last char
- if (!$inQuotes && !$controlCharacterOrSpace) {
- $tmp .= '*';
+
+ $this->searchEngine = call_user_func(array($className, 'getInstance'));
}
-
- return $tmp;
+
+ return $this->searchEngine;
}
/**
- * Returns MySQL's minimum word length for fulltext indices.
- *
- * @return integer
+ * @see \wcf\system\search\ISearchEngine::search()
*/
- public static function getFulltextMinimumWordLength() {
- if (self::$ftMinWordLen === null) {
- $sql = "SHOW VARIABLES LIKE ?";
- $statement = WCF::getDB()->prepareStatement($sql);
-
- try {
- $statement->execute(array('ft_min_word_len'));
- $row = $statement->fetchArray();
- }
- catch (DatabaseException $e) {
- // fallback if user is disallowed to issue 'SHOW VARIABLES'
- $row = array('Value' => 4);
- }
-
- self::$ftMinWordLen = $row['Value'];
- }
-
- return self::$ftMinWordLen;
+ public function search($q, array $objectTypes, $subjectOnly = false, PreparedStatementConditionBuilder $searchIndexCondition = null, array $additionalConditions = array(), $orderBy = 'time DESC', $limit = 1000) {
+ return $this->getSearchEngine()->search($q, $objectTypes, $subjectOnly, $searchIndexCondition, $additionalConditions, $orderBy, $limit);
}
}
<?php
namespace wcf\system\search;
-use wcf\data\object\type\ObjectType;
use wcf\data\object\type\ObjectTypeCache;
-use wcf\data\object\type\ObjectTypeList;
use wcf\data\package\Package;
use wcf\data\package\PackageList;
use wcf\system\exception\SystemException;
use wcf\system\SingletonFactory;
-use wcf\system\WCF;
/**
* Manages the search index.
* @subpackage system.search
* @category Community Framework
*/
-class SearchIndexManager extends SingletonFactory {
+class SearchIndexManager extends SingletonFactory implements ISearchIndexManager {
/**
* list of available object types
* @var array
*/
protected static $packages = array();
+ /**
+ * search index manager object
+ * @var \wcf\system\search\ISearchIndexManager
+ */
+ protected $searchIndexManager = null;
+
/**
* @see \wcf\system\SingletonFactory::init()
*/
}
/**
- * Adds a new entry.
+ * Returns the search index manager object.
*
- * @param string $objectType
- * @param integer $objectID
- * @param string $message
- * @param string $subject
- * @param integer $time
- * @param integer $userID
- * @param string $username
- * @param integer $languageID
- * @param string $metaData
+ * @return \wcf\system\search\ISearchIndexManager
*/
- public function add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '') {
- if ($languageID === null) $languageID = 0;
+ protected function getSearchIndexManager() {
+ if ($this->searchIndexManager === null) {
+ $className = '';
+ if (SEARCH_ENGINE != 'mysql') {
+ $className = 'wcf\system\search\\'.SEARCH_ENGINE.'\\'.ucfirst(SEARCH_ENGINE).'SearchEngine';
+ if (!class_exists($className)) {
+ $className = '';
+ }
+ }
+
+ // fallback to MySQL
+ if (empty($className)) {
+ $className = 'wcf\system\search\mysql\MysqlSearchIndexManager';
+ }
+
+ $this->searchIndexManager = call_user_func(array($className, 'getInstance'));
+ }
- // save new entry
- $sql = "REPLACE INTO " . self::getTableName($objectType) . "
- (objectID, subject, message, time, userID, username, languageID, metaData)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(array($objectID, $subject, $message, $time, $userID, $username, $languageID, $metaData));
+ return $this->searchIndexManager;
}
/**
- * Updates the search index.
- *
- * @param string $objectType
- * @param integer $objectID
- * @param string $message
- * @param string $subject
- * @param integer $time
- * @param integer $userID
- * @param string $username
- * @param integer $languageID
- * @param string $metaData
+ * @see \wcf\system\search\ISearchIndexManager::add()
*/
- public function update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '') {
- // delete existing entry
- $this->delete($objectType, array($objectID));
-
- // save new entry
- $this->add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID, $metaData);
+ public function add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array()) {
+ $this->getSearchIndexManager()->add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID, $metaData, $additionalData);
}
/**
- * Deletes search index entries.
- *
- * @param string $objectType
- * @param array<integer> $objectIDs
+ * @see \wcf\system\search\ISearchIndexManager::update()
*/
- public function delete($objectType, array $objectIDs) {
- $sql = "DELETE FROM " . self::getTableName($objectType) . "
- WHERE objectID = ?";
- $statement = WCF::getDB()->prepareStatement($sql);
- WCF::getDB()->beginTransaction();
- foreach ($objectIDs as $objectID) {
- $statement->execute(array($objectID));
- }
- WCF::getDB()->commitTransaction();
+ public function update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array()) {
+ $this->getSearchIndexManager()->update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID, $metaData, $additionalData);
}
/**
- * Resets the search index.
- *
- * @param string $objectType
+ * @see \wcf\system\search\ISearchIndexManager::delete()
*/
- public function reset($objectType) {
- $sql = "TRUNCATE TABLE " . self::getTableName($objectType);
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute();
+ public function delete($objectType, array $objectIDs) {
+ $this->getSearchIndexManager()->delete($objectType, $objectIDs);
}
/**
- * Creates the search index tables for all registered, searchable object types.
+ * @see \wcf\system\search\ISearchIndexManager::reset()
*/
- public static function createSearchIndexTables() {
- // get definition id
- $sql = "SELECT definitionID
- FROM wcf".WCF_N."_object_type_definition
- WHERE definitionName = ?";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(array('com.woltlab.wcf.searchableObjectType'));
- $row = $statement->fetchArray();
-
- $objectTypeList = new ObjectTypeList();
- $objectTypeList->getConditionBuilder()->add("object_type.definitionID = ?", array($row['definitionID']));
- $objectTypeList->readObjects();
-
- foreach ($objectTypeList as $objectType) {
- self::createSearchIndexTable($objectType);
- }
+ public function reset($objectType) {
+ $this->getSearchIndexManager()->reset($objectType);
}
/**
- * Creates the search index table for given object type. Returns true if the
- * table was created, otherwise false.
- *
- * @param \wcf\data\object\type\ObjectType $objectType
- * @return boolean
+ * @see \wcf\system\search\ISearchIndexManager::createSearchIndices()
*/
- protected static function createSearchIndexTable(ObjectType $objectType) {
- $tableName = self::getTableName($objectType);
-
- // check if table already exists
- $sql = "SELECT COUNT(*) AS count
- FROM wcf".WCF_N."_package_installation_sql_log
- WHERE sqlTable = ?";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(array($tableName));
- $row = $statement->fetchArray();
- if ($row['count']) {
- // table already exists
- return false;
- }
-
- $columns = array(
- array('name' => 'objectID', 'data' => array('length' => 10, 'notNull' => true, 'type' => 'int')),
- array('name' => 'subject', 'data' => array('default' => '', 'length' => 255, 'notNull' => true, 'type' => 'varchar')),
- array('name' => 'message', 'data' => array('type' => 'mediumtext')),
- array('name' => 'metaData', 'data' => array('type' => 'mediumtext')),
- array('name' => 'time', 'data' => array('default' => 0, 'length' => 10, 'notNull' => true, 'type' => 'int')),
- array('name' => 'userID', 'data' => array('default' => '', 'length' => 10, 'type' => 'int')),
- array('name' => 'username', 'data' => array('default' => '', 'length' => 255,'notNull' => true, 'type' => 'varchar')),
- array('name' => 'languageID', 'data' => array('default' => 0, 'length' => 10, 'notNull' => true, 'type' => 'int'))
- );
-
- $indices = array(
- array('name' => 'objectAndLanguage', 'data' => array('columns' => 'objectID, languageID', 'type' => 'UNIQUE')),
- array('name' => 'fulltextIndex', 'data' => array('columns' => 'subject, message, metaData', 'type' => 'FULLTEXT')),
- array('name' => 'fulltextIndexSubjectOnly', 'data' => array('columns' => 'subject', 'type' => 'FULLTEXT')),
- array('name' => 'language', 'data' => array('columns' => 'languageID', 'type' => 'KEY')),
- array('name' => 'user', 'data' => array('columns' => 'userID, time', 'type'=> 'KEY'))
- );
-
- WCF::getDB()->getEditor()->createTable($tableName, $columns, $indices);
-
- // add comment
- $sql = "ALTER TABLE ".$tableName."
- COMMENT = ?";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(array(' Search index for ' . $objectType->objectType));
-
- // log table
- $sql = "INSERT INTO wcf".WCF_N."_package_installation_sql_log
- (packageID, sqlTable)
- VALUES (?, ?)";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute(array(
- $objectType->packageID,
- $tableName
- ));
-
- return true;
+ public function createSearchIndices() {
+ $this->getSearchIndexManager()->createSearchIndices();
}
/**
--- /dev/null
+<?php
+namespace wcf\system\search\mysql;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\database\DatabaseException;
+use wcf\system\exception\SystemException;
+use wcf\system\search\AbstractSearchEngine;
+use wcf\system\search\SearchIndexManager;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+use wcf\system\search\SearchEngine;
+
+/**
+ * Search engine using MySQL's FULLTEXT index.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+class MysqlSearchEngine extends AbstractSearchEngine {
+ /**
+ * MySQL's minimum word length for fulltext indices
+ * @var integer
+ */
+ protected static $ftMinWordLen = null;
+
+ /**
+ * @see \wcf\system\search\ISearchEngine::search()
+ */
+ public function search($q, array $objectTypes, $subjectOnly = false, PreparedStatementConditionBuilder $searchIndexCondition = null, array $additionalConditions = array(), $orderBy = 'time DESC', $limit = 1000) {
+ // handle sql types
+ $fulltextCondition = null;
+ $relevanceCalc = '';
+ if (!empty($q)) {
+ $q = $this->parseSearchQuery($q);
+
+ $fulltextCondition = new PreparedStatementConditionBuilder(false);
+ switch (WCF::getDB()->getDBType()) {
+ case 'wcf\system\database\MySQLDatabase':
+ $fulltextCondition->add("MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST (? IN BOOLEAN MODE)", array($q));
+ break;
+
+ case 'wcf\system\database\PostgreSQLDatabase':
+ // replace * with :*
+ $q = str_replace('*', ':*', $q);
+
+ $fulltextCondition->add("fulltextIndex".($subjectOnly ? "SubjectOnly" : '')." @@ to_tsquery(?)", array($q));
+ break;
+
+ default:
+ throw new SystemException("your database type doesn't support fulltext search");
+ break;
+ }
+
+ if ($orderBy == 'relevance ASC' || $orderBy == 'relevance DESC') {
+ switch (WCF::getDB()->getDBType()) {
+ case 'wcf\system\database\MySQLDatabase':
+ $relevanceCalc = "MATCH (subject".(!$subjectOnly ? ', message, metaData' : '').") AGAINST ('".escapeString($q)."') + (5 / (1 + POW(LN(1 + (".TIME_NOW." - time) / 2592000), 2))) AS relevance";
+ break;
+
+ case 'wcf\system\database\PostgreSQLDatabase':
+ $relevanceCalc = "ts_rank_cd(fulltextIndex".($subjectOnly ? "SubjectOnly" : '').", '".escapeString($q)."') AS relevance";
+ break;
+ }
+ }
+ }
+
+ // build search query
+ $sql = '';
+ $parameters = array();
+ foreach ($objectTypes as $objectTypeName) {
+ $objectType = SearchEngine::getInstance()->getObjectType($objectTypeName);
+ if (!empty($sql)) $sql .= "\nUNION\n";
+ $additionalConditionsConditionBuilder = (isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : null);
+ if (($specialSQL = $objectType->getSpecialSQLQuery($fulltextCondition, $searchIndexCondition, $additionalConditionsConditionBuilder, $orderBy))) {
+ $sql .= "(".$specialSQL.")";
+ }
+ else {
+ $sql .= "(
+ SELECT ".$objectType->getIDFieldName()." AS objectID,
+ ".$objectType->getSubjectFieldName()." AS subject,
+ ".$objectType->getTimeFieldName()." AS time,
+ ".$objectType->getUsernameFieldName()." AS username,
+ '".$objectTypeName."' AS objectType
+ ".($relevanceCalc ? ',search_index.relevance' : '')."
+ FROM ".$objectType->getTableName()."
+ INNER JOIN (
+ SELECT objectID
+ ".($relevanceCalc ? ','.$relevanceCalc : '')."
+ FROM ".SearchIndexManager::getTableName($objectTypeName)."
+ WHERE ".($fulltextCondition !== null ? $fulltextCondition : '')."
+ ".(($searchIndexCondition !== null && $searchIndexCondition->__toString()) ? ($fulltextCondition !== null ? "AND " : '').$searchIndexCondition : '')."
+ ".(!empty($orderBy) && $fulltextCondition === null ? 'ORDER BY '.$orderBy : '')."
+ LIMIT 1000
+ ) search_index
+ ON (".$objectType->getIDFieldName()." = search_index.objectID)
+ ".$objectType->getJoins()."
+ ".(isset($additionalConditions[$objectTypeName]) ? $additionalConditions[$objectTypeName] : '')."
+ )";
+ }
+
+ if ($fulltextCondition !== null) $parameters = array_merge($parameters, $fulltextCondition->getParameters());
+ if ($searchIndexCondition !== null) $parameters = array_merge($parameters, $searchIndexCondition->getParameters());
+ if (isset($additionalConditions[$objectTypeName])) $parameters = array_merge($parameters, $additionalConditions[$objectTypeName]->getParameters());
+ }
+ if (empty($sql)) {
+ throw new SystemException('no object types given');
+ }
+
+ if (!empty($orderBy)) {
+ $sql .= " ORDER BY " . $orderBy;
+ }
+
+ // send search query
+ $messages = array();
+ $statement = WCF::getDB()->prepareStatement($sql, $limit);
+ $statement->execute($parameters);
+ while ($row = $statement->fetchArray()) {
+ $messages[] = array(
+ 'objectID' => $row['objectID'],
+ 'objectType' => $row['objectType']
+ );
+ }
+
+ return $messages;
+ }
+
+ /**
+ * Manipulates the search term (< and > used as quotation marks):
+ *
+ * - <test foo> becomes <+test* +foo*>
+ * - <test -foo bar> becomes <+test* -foo* +bar*>
+ * - <test "foo bar"> becomes <+test* +"foo bar">
+ *
+ * @see http://dev.mysql.com/doc/refman/5.5/en/fulltext-boolean.html
+ *
+ * @param string $query
+ */
+ protected function parseSearchQuery($query) {
+ $query = StringUtil::trim($query);
+
+ // expand search terms with a * unless they're encapsulated with quotes
+ $inQuotes = false;
+ $previousChar = $tmp = '';
+ $controlCharacterOrSpace = false;
+ $chars = array('+', '-', '*');
+ $ftMinWordLen = self::getFulltextMinimumWordLength();
+ for ($i = 0, $length = mb_strlen($query); $i < $length; $i++) {
+ $char = mb_substr($query, $i, 1);
+
+ if ($inQuotes) {
+ if ($char == '"') {
+ $inQuotes = false;
+ }
+ }
+ else {
+ if ($char == '"') {
+ $inQuotes = true;
+ }
+ else {
+ if ($char == ' ' && !$controlCharacterOrSpace) {
+ $controlCharacterOrSpace = true;
+ $tmp .= '*';
+ }
+ else if (in_array($char, $chars)) {
+ $controlCharacterOrSpace = true;
+ }
+ else {
+ $controlCharacterOrSpace = false;
+ }
+ }
+ }
+
+ /*
+ * prepend a plus sign (logical AND) if ALL these conditions are given:
+ *
+ * 1) previous character:
+ * - is empty (start of string)
+ * - is a space (MySQL uses spaces to separate words)
+ *
+ * 2) not within quotation marks
+ *
+ * 3) current char:
+ * - is NOT +, - or *
+ */
+ if (($previousChar == '' || $previousChar == ' ') && !$inQuotes && !in_array($char, $chars)) {
+ // check if the term is shorter than MySQL's ft_min_word_len
+
+ if ($i + $ftMinWordLen <= $length) {
+ $term = '';// $char;
+ for ($j = $i, $innerLength = $ftMinWordLen + $i; $j < $innerLength; $j++) {
+ $currentChar = mb_substr($query, $j, 1);
+ if ($currentChar == '"' || $currentChar == ' ' || in_array($currentChar, $chars)) {
+ break;
+ }
+
+ $term .= $currentChar;
+ }
+
+ if (mb_strlen($term) == $ftMinWordLen) {
+ $tmp .= '+';
+ }
+ }
+ }
+
+ $tmp .= $char;
+ $previousChar = $char;
+ }
+
+ // handle last char
+ if (!$inQuotes && !$controlCharacterOrSpace) {
+ $tmp .= '*';
+ }
+
+ return $tmp;
+ }
+
+ /**
+ * Returns MySQL's minimum word length for fulltext indices.
+ *
+ * @return integer
+ */
+ protected static function getFulltextMinimumWordLength() {
+ if (self::$ftMinWordLen === null) {
+ $sql = "SHOW VARIABLES LIKE ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+
+ try {
+ $statement->execute(array('ft_min_word_len'));
+ $row = $statement->fetchArray();
+ }
+ catch (DatabaseException $e) {
+ // fallback if user is disallowed to issue 'SHOW VARIABLES'
+ $row = array('Value' => 4);
+ }
+
+ self::$ftMinWordLen = $row['Value'];
+ }
+
+ return self::$ftMinWordLen;
+ }
+}
--- /dev/null
+<?php
+namespace wcf\system\search\mysql;
+use wcf\data\object\type\ObjectType;
+use wcf\system\search\AbstractSearchIndexManager;
+use wcf\system\WCF;
+use wcf\system\search\SearchIndexManager;
+
+/**
+ * Search engine using MySQL's FULLTEXT index.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2014 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.search
+ * @category Community Framework
+ */
+class MysqlSearchIndexManager extends AbstractSearchIndexManager {
+ /**
+ * @see \wcf\system\search\ISearchIndexManager::add()
+ */
+ public function add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array()) {
+ if ($languageID === null) $languageID = 0;
+
+ // save new entry
+ $sql = "REPLACE INTO " . SearchIndexManager::getTableName($objectType) . "
+ (objectID, subject, message, time, userID, username, languageID, metaData)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute(array($objectID, $subject, $message, $time, $userID, $username, $languageID, $metaData));
+ }
+
+ /**
+ * @see \wcf\system\search\ISearchIndexManager::update()
+ */
+ public function update($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID = null, $metaData = '', array $additionalData = array()) {
+ // delete existing entry
+ $this->delete($objectType, array($objectID));
+
+ // save new entry
+ $this->add($objectType, $objectID, $message, $subject, $time, $userID, $username, $languageID, $metaData, $additionalData);
+ }
+
+ /**
+ * @see \wcf\system\search\ISearchIndexManager::delete()
+ */
+ public function delete($objectType, array $objectIDs) {
+ $sql = "DELETE FROM " . SearchIndexManager::getTableName($objectType) . "
+ WHERE objectID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ WCF::getDB()->beginTransaction();
+ foreach ($objectIDs as $objectID) {
+ $statement->execute(array($objectID));
+ }
+ WCF::getDB()->commitTransaction();
+ }
+
+ /**
+ * @see \wcf\system\search\ISearchIndexManager::reset()
+ */
+ public function reset($objectType) {
+ $sql = "TRUNCATE TABLE " . SearchIndexManager::getTableName($objectType);
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute();
+ }
+
+ /**
+ * @see \wcf\system\search\AbstractSearchIndexManager::createSearchIndex()
+ */
+ protected function createSearchIndex(ObjectType $objectType) {
+ $tableName = SearchIndexManager::getTableName($objectType);
+
+ // check if table already exists
+ $sql = "SELECT COUNT(*) AS count
+ FROM wcf".WCF_N."_package_installation_sql_log
+ WHERE sqlTable = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute(array($tableName));
+ $row = $statement->fetchArray();
+ if ($row['count']) {
+ // table already exists
+ return false;
+ }
+
+ $columns = array(
+ array('name' => 'objectID', 'data' => array('length' => 10, 'notNull' => true, 'type' => 'int')),
+ array('name' => 'subject', 'data' => array('default' => '', 'length' => 255, 'notNull' => true, 'type' => 'varchar')),
+ array('name' => 'message', 'data' => array('type' => 'mediumtext')),
+ array('name' => 'metaData', 'data' => array('type' => 'mediumtext')),
+ array('name' => 'time', 'data' => array('default' => 0, 'length' => 10, 'notNull' => true, 'type' => 'int')),
+ array('name' => 'userID', 'data' => array('default' => '', 'length' => 10, 'type' => 'int')),
+ array('name' => 'username', 'data' => array('default' => '', 'length' => 255,'notNull' => true, 'type' => 'varchar')),
+ array('name' => 'languageID', 'data' => array('default' => 0, 'length' => 10, 'notNull' => true, 'type' => 'int'))
+ );
+
+ $indices = array(
+ array('name' => 'objectAndLanguage', 'data' => array('columns' => 'objectID, languageID', 'type' => 'UNIQUE')),
+ array('name' => 'fulltextIndex', 'data' => array('columns' => 'subject, message, metaData', 'type' => 'FULLTEXT')),
+ array('name' => 'fulltextIndexSubjectOnly', 'data' => array('columns' => 'subject', 'type' => 'FULLTEXT')),
+ array('name' => 'language', 'data' => array('columns' => 'languageID', 'type' => 'KEY')),
+ array('name' => 'user', 'data' => array('columns' => 'userID, time', 'type'=> 'KEY'))
+ );
+
+ WCF::getDB()->getEditor()->createTable($tableName, $columns, $indices);
+
+ // add comment
+ $sql = "ALTER TABLE ".$tableName."
+ COMMENT = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute(array(' Search index for ' . $objectType->objectType));
+
+ // log table
+ $sql = "INSERT INTO wcf".WCF_N."_package_installation_sql_log
+ (packageID, sqlTable)
+ VALUES (?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute(array(
+ $objectType->packageID,
+ $tableName
+ ));
+
+ return true;
+ }
+}
define('EXTERNAL_LINK_TARGET_BLANK', 0);
define('URL_LEGACY_MODE', 0);
define('URL_TO_LOWERCASE', 1);
+define('SEARCH_ENGINE', 'mysql');