Newer
Older
<?php
/*********************************************************************
module.search.php
Search Engine for osTicket
This module defines the pieces for a search engine for osTicket.
Searching can be performed by various search engine backends which can
make use of the features of various search providers.
A reference search engine backend is provided which uses MySQL MyISAM
tables. This default backend should not be used on Galera clusters.
Jared Hancock <jared@osticket.com>
Peter Rotich <peter@osticket.com>
Copyright (c) 2006-2013 osTicket
http://www.osticket.com
Released under the GNU General Public License WITHOUT ANY WARRANTY.
See LICENSE.TXT for details.
vim: expandtab sw=4 ts=4 sts=4:
**********************************************************************/
require_once INCLUDE_DIR . 'class.role.php';
require_once INCLUDE_DIR . 'class.list.php';
abstract class SearchBackend {
static $id = false;
static $registry = array();
const SORT_RELEVANCE = 1;
const SORT_RECENT = 2;
const SORT_OLDEST = 3;
const PERM_EVERYTHING = 'search.all';
static protected $perms = array(
self::PERM_EVERYTHING => array(
'title' => /* @trans */ 'Search',
'desc' => /* @trans */ 'See all tickets in search results, regardless of access',
'primary' => true,
abstract function update($model, $id, $content, $new=false, $attrs=array());
abstract function find($query, QuerySet $criteria, $addRelevance=true);
static function register($backend=false) {
$backend = $backend ?: get_called_class();
if ($backend::$id == false)
throw new Exception('SearchBackend must define an ID');
static::$registry[$backend::$id] = $backend;
}
function getInstance($id) {
if (!isset(self::$registry[$id]))
return null;
return new self::$registry[$id]();
}
static function getPermissions() {
return self::$perms;
}
RolePermission::register(/* @trans */ 'Miscellaneous', SearchBackend::getPermissions());
// Register signals to intercept saving of various content throughout the
// system
class SearchInterface {
var $backend;
function __construct() {
$this->bootstrap();
}
function find($query, QuerySet $criteria, $addRelevance=true) {
$query = Format::searchable($query);
return $this->backend->find($query, $criteria, $addRelevance);
}
function update($model, $id, $content, $new=false, $attrs=array()) {
if ($this->backend)
$this->backend->update($model, $id, $content, $new, $attrs);
}
function createModel($model) {
return $this->updateModel($model, true);
}
function deleteModel($model) {
if ($this->backend)
$this->backend->delete($model);
}
function updateModel($model, $new=false) {
// The MySQL backend does not need to index attributes of the
// various models, because those other attributes are available in
// the local database in other tables.
switch (true) {
case $model instanceof ThreadEntry:
// Only index an entry for threads if a human created the
// content
if (!$model->getUserId() && !$model->getStaffId())
break;
$this->update($model, $model->getId(),
$model->getBody()->getSearchable(),
$new === true,
array(
'title' => $model->getTitle(),
'created' => $model->getCreateDate(),
)
);
break;
case $model instanceof Ticket:
$cdata = array();
foreach ($model->loadDynamicData() as $a)
if ($v = $a->getSearchable())
$cdata[] = $v;
$this->update($model, $model->getId(),
trim(implode("\n", $cdata)),
array(
'title'=> Format::searchable($model->getSubject()),
'number'=> $model->getNumber(),
'status'=> $model->getStatus(),
'topic_id'=> $model->getTopicId(),
'priority_id'=> $model->getPriorityId(),
// Stats (comments, attachments)
// Access constraints
'dept_id'=> $model->getDeptId(),
'staff_id'=> $model->getStaffId(),
'team_id'=> $model->getTeamId(),
// Sorting and ranging preferences
'created'=> $model->getCreateDate(),
// Add last-updated timestamp
)
);
break;
case $model instanceof User:
$cdata = array();
foreach ($model->getDynamicData($false) as $e)
foreach ($e->getAnswers() as $tag=>$a)
if ($tag != 'subject' && ($v = $a->getSearchable()))
$cdata[] = $v;
$this->update($model, $model->getId(),
trim(implode("\n", $cdata)),
array(
'title'=> Format::searchable($model->getFullName()),
'emails'=> $model->emails->asArray(),
'org_id'=> $model->getOrgId(),
'created'=> $model->getCreateDate(),
)
);
break;
case $model instanceof Organization:
$cdata = array();
foreach ($model->getDynamicData(false) as $e)
foreach ($e->getAnswers() as $a)
if ($v = $a->getSearchable())
$cdata[] = $v;
$this->update($model, $model->getId(),
trim(implode("\n", $cdata)),
array(
'title'=> Format::searchable($model->getName()),
'created'=> $model->getCreateDate(),
)
);
break;
case $model instanceof FAQ:
$this->update($model, $model->getId(),
$model->getSearchableAnswer(),
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
array(
'title'=> Format::searchable($model->getQuestion()),
'keywords'=> $model->getKeywords(),
'topics'=> $model->getHelpTopicsIds(),
'category_id'=> $model->getCategoryId(),
'created'=> $model->getCreateDate(),
)
);
break;
default:
// Not indexed
break;
}
}
function bootstrap() {
// Determine the backend
if (defined('SEARCH_BACKEND'))
$bk = SearchBackend::getInstance(SEARCH_BACKEND);
if (!$bk && !($bk = SearchBackend::getInstance('mysql')))
// No backend registered or defined
return false;
$this->backend = $bk;
$this->backend->bootstrap();
$self = $this;
// Thread entries
// Tickets, which can be edited as well
// Knowledgebase articles (FAQ and canned responses)
// Users, organizations
Signal::connect('threadentry.created', array($this, 'createModel'));
Signal::connect('ticket.created', array($this, 'createModel'));
Signal::connect('user.created', array($this, 'createModel'));
Signal::connect('organization.created', array($this, 'createModel'));
Signal::connect('model.created', array($this, 'createModel'), 'FAQ');
Signal::connect('model.updated', array($this, 'updateModel'));
Signal::connect('model.deleted', array($this, 'deleteModel'));
require_once(INCLUDE_DIR.'class.config.php');
class MySqlSearchConfig extends Config {
var $table = CONFIG_TABLE;
function __construct() {
parent::__construct("mysqlsearch");
}
}
class MysqlSearchBackend extends SearchBackend {
static $id = 'mysql';
static $BATCH_SIZE = 30;
// Only index 20 batches per cron run
var $max_batches = 60;
var $_reindexed = 0;
function __construct() {
$this->SEARCH_TABLE = TABLE_PREFIX . '_search';
}
function getConfig() {
if (!isset($this->config))
$this->config = new MySqlSearchConfig();
return $this->config;
}
if ($this->getConfig()->get('reindex', true))
Signal::connect('cron', array($this, 'IndexOldStuff'));
}
function update($model, $id, $content, $new=false, $attrs=array()) {
if (!($type=ObjectModel::getType($model)))
return;
if ($model instanceof Ticket)
$attrs['title'] = $attrs['number'].' '.$attrs['title'];
$content .=' '.implode("\n", $attrs['emails']);
$title = $attrs['title'] ?: '';
if (!$content && !$title)
return;
if (!$id)
return;
$sql = 'REPLACE INTO '.$this->SEARCH_TABLE
. ' SET object_type='.db_input($type)
. ', object_id='.db_input($id)
. ', content='.db_input($content)
. ', title='.db_input($title);
return db_query($sql, false);
function delete($model) {
switch (true) {
case $model instanceof Thread:
$sql = 'DELETE s.* FROM '.$this->SEARCH_TABLE
. " s JOIN ".THREAD_ENTRY_TABLE." h ON (h.id = s.object_id) "
. " WHERE s.object_type='H'"
. ' AND h.thread_id='.db_input($model->getId());
return db_query($sql);
default:
if (!($type = ObjectModel::getType($model)))
return;
$sql = 'DELETE FROM '.$this->SEARCH_TABLE
. ' WHERE object_type='.db_input($type)
. ' AND object_id='.db_input($model->getId());
return db_query($sql);
}
}
// Quote things like email addresses
function quote($query) {
$parts = array();
if (!preg_match_all('`(?:([^\s"\']+)|"[^"]*"|\'[^\']*\')(\s*)`', $query, $parts,
PREG_SET_ORDER))
return $query;
$results = array();
foreach ($parts as $m) {
// Check for quoting
if ($m[1] // Already quoted?
&& preg_match('`@`u', $m[0])
) {
$char = strpos($m[1], '"') ? "'" : '"';
$m[0] = $char . $m[0] . $char;
}
$results[] = $m[0].$m[2];
return implode('', $results);
function find($query, QuerySet $criteria, $addRelevance=true) {
// MySQL usually doesn't handle words shorter than three letters
// (except with special configuration)
if (strlen($query) < 3)
return $criteria;
$criteria = clone $criteria;
$mode = ' IN NATURAL LANGUAGE MODE';
// According to the MySQL full text boolean mode, this grammar is
// assumed:
// see http://dev.mysql.com/doc/refman/5.6/en/fulltext-boolean.html
//
// PREOP = [<>~+-]
// POSTOP = [*]
// WORD = [\w][\w-]*
// TERM = PREOP? WORD POSTOP?
// QWORD = " [^"]+ "
// PARENS = \( { { TERM | QWORD } { \s+ { TERM | QWORD } }+ } \)
// EXPR = { PREOP? PARENS | TERM | QWORD }
// BOOLEAN = EXPR { \s+ EXPR }*
//
// Changing '{' for (?: and '}' for ')', collapsing whitespace, we
// have this regular expression
$BOOLEAN = '(?:[<>~+-]?\((?:(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))+)\)|[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?\((?:(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+")(?:\s+(?:[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))+)\)|[<>~+-]?[\w][\w-]*[*]?|"[^"]+"))*';
// Require the use of at least one operator and conform to the
// boolean mode grammar
if (preg_match('`(^|\s)["()<>~+-]`u', $query, $T = array())
&& preg_match("`^{$BOOLEAN}$`u", $query, $T = array())
) {
// If using boolean operators, search in boolean mode. This regex
// will ensure proper placement of operators, whitespace, and quotes
// in an effort to avoid crashing the query at MySQL
$query = $this->quote($query);
$mode = ' IN BOOLEAN MODE';
#elseif (count(explode(' ', $query)) == 1)
$search = 'MATCH (Z1.title, Z1.content) AGAINST ('.db_input($query).$mode.')';
if ($addRelevance) {
$criteria = $criteria->extra(array(
'select' => array(
'__relevance__' => 'Z1.`relevance`',
),
));
}
$criteria->extra(array(
'tables' => array(
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
"(SELECT COALESCE(Z3.`object_id`, Z5.`ticket_id`, Z8.`ticket_id`) as `ticket_id`, SUM({}) AS `relevance` FROM `:_search` Z1 LEFT JOIN `:thread_entry` Z2 ON (Z1.`object_type` = 'H' AND Z1.`object_id` = Z2.`id`) LEFT JOIN `:thread` Z3 ON (Z2.`thread_id` = Z3.`id` AND Z3.`object_type` = 'T') LEFT JOIN `:ticket` Z5 ON (Z1.`object_type` = 'T' AND Z1.`object_id` = Z5.`ticket_id`) LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') LEFT JOIN :ticket Z8 ON (Z8.`user_id` = Z6.`id`) WHERE {} GROUP BY `ticket_id`) Z1"),
)
));
$criteria->filter(array('ticket_id'=>new SqlCode('Z1.`ticket_id`')));
break;
case 'User':
$criteria->extra(array(
'select' => array(
'__relevance__' => 'Z1.`relevance`',
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
"(SELECT Z6.`id` as `user_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:user` Z6 ON (Z6.`id` = Z1.`object_id` and Z1.`object_type` = 'U') LEFT JOIN `:organization` Z7 ON (Z7.`id` = Z1.`object_id` AND Z7.`id` = Z6.`org_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"),
$criteria->filter(array('id'=>new SqlCode('Z1.`user_id`')));
break;
case 'Organization':
$criteria->extra(array(
'select' => array(
'__relevance__' => 'Z1.`relevance`',
),
'tables' => array(
str_replace(array(':', '{}'), array(TABLE_PREFIX, $search),
"(SELECT Z2.`id` as `org_id`, {} AS `relevance` FROM `:_search` Z1 LEFT JOIN `:organization` Z2 ON (Z2.`id` = Z1.`object_id` AND Z1.`object_type` = 'O') WHERE {}) Z1"),
)
));
$criteria->filter(array('id'=>new SqlCode('Z1.`org_id`')));
break;
// TODO: Ensure search table exists;
if (false) {
// TODO: Create the search table automatically
// $class::createSearchTable();
}
static function createSearchTable() {
// Use InnoDB with Galera, MyISAM with v5.5, and the database
// default otherwise
$sql = "select count(*) from information_schema.tables where
table_schema='information_schema' and table_name =
'INNODB_FT_CONFIG'";
$mysql56 = db_result(db_query($sql));
$sql = "show status like 'wsrep_local_state'";
$galera = db_result(db_query($sql));
if ($galera && !$mysql56)
throw new Exception('Galera cannot be used with MyISAM tables. Upgrade to MariaDB 10 / MySQL 5.6 is required');
$engine = $galera ? 'InnodB' : ($mysql56 ? '' : 'MyISAM');
if ($engine)
$engine = 'ENGINE='.$engine;
$sql = 'CREATE TABLE IF NOT EXISTS '.TABLE_PREFIX."_search (
`object_type` varchar(8) not null,
`object_id` int(11) unsigned not null,
`title` text collate utf8_general_ci,
`content` text collate utf8_general_ci,
primary key `object` (`object_type`, `object_id`),
fulltext key `search` (`title`, `content`)
) $engine CHARSET=utf8";
if (!db_query($sql))
return false;
// Start rebuilding the index
$config = new MySqlSearchConfig();
$config->set('reindex', 1);
}
/**
* Cooperates with the cron system to automatically find content that is
* not indexed in the _search table and add it to the index.
*/
function IndexOldStuff() {
$class = get_class();
$auto_create = function($db_error) use ($class) {
if ($db_error != 1146)
// Perform the standard error handling
return true;
// Create the search table automatically
};
// THREADS ----------------------------------
$sql = "SELECT A1.`id`, A1.`title`, A1.`body`, A1.`format` FROM `".THREAD_ENTRY_TABLE."` A1
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='H')
WHERE A2.`object_id` IS NULL AND (A1.poster <> 'SYSTEM')
AND (LENGTH(A1.`title`) + LENGTH(A1.`body`) > 0)
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$body = ThreadEntryBody::fromFormattedText($row[2], $row[3]);
$body = $body->getSearchable();
$title = Format::searchable($row[1]);
if (!$body && !$title)
continue;
$record = array('H', $row[0], $title, $body);
if (!$this->__index($record))
return;
}
// TICKETS ----------------------------------
$sql = "SELECT A1.`ticket_id` FROM `".TICKET_TABLE."` A1
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`ticket_id` = A2.`object_id` AND A2.`object_type`='T')
WHERE A2.`object_id` IS NULL
ORDER BY A1.`ticket_id` DESC LIMIT 300";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
if (!($ticket = Ticket::lookup($row[0])))
continue;
$cdata = $ticket->loadDynamicData();
$content = array();
foreach ($cdata as $k=>$a)
if ($k != 'subject' && ($v = $a->getSearchable()))
$content[] = $v;
$record = array('T', $ticket->getId(),
Format::searchable($ticket->getNumber().' '.$ticket->getSubject()),
if (!$this->__index($record))
return;
}
// USERS ------------------------------------
$sql = "SELECT A1.`id` FROM `".USER_TABLE."` A1
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='U')
WHERE A2.`object_id` IS NULL
ORDER BY A1.`id` DESC";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$user = User::lookup($row[0]);
$cdata = $user->getDynamicData();
$content = array();
foreach ($user->emails as $e)
$content[] = $e->address;
foreach ($cdata as $e)
foreach ($e->getAnswers() as $a)
if ($c = $a->getSearchable())
$content[] = $c;
$record = array('U', $user->getId(),
Format::searchable($user->getFullName()),
trim(implode("\n", $content)));
if (!$this->__index($record))
return;
}
// ORGANIZATIONS ----------------------------
$sql = "SELECT A1.`id` FROM `".ORGANIZATION_TABLE."` A1
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`id` = A2.`object_id` AND A2.`object_type`='O')
WHERE A2.`object_id` IS NULL
ORDER BY A1.`id` DESC";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$org = Organization::lookup($row[0]);
$cdata = $org->getDynamicData();
$content = array();
foreach ($cdata as $e)
foreach ($e->getAnswers() as $a)
if ($c = $a->getSearchable())
$content[] = $c;
$record = array('O', $org->getId(),
Format::searchable($org->getName()),
trim(implode("\n", $content)));
if (!$this->__index($record))
return null;
}
// KNOWLEDGEBASE ----------------------------
require_once INCLUDE_DIR . 'class.faq.php';
$sql = "SELECT A1.`faq_id` FROM `".FAQ_TABLE."` A1
LEFT JOIN `".TABLE_PREFIX."_search` A2 ON (A1.`faq_id` = A2.`object_id` AND A2.`object_type`='K')
WHERE A2.`object_id` IS NULL
ORDER BY A1.`faq_id` DESC";
if (!($res = db_query_unbuffered($sql, $auto_create)))
return false;
while ($row = db_fetch_row($res)) {
$faq = FAQ::lookup($row[0]);
$q = $faq->getQuestion();
if ($k = $faq->getKeywords())
$q = $k.' '.$q;
$record = array('K', $faq->getId(),
Format::searchable($q),
if (!$this->__index($record))
return;
}
// FILES ------------------------------------
// Flush non-full batch of records
$this->__index(null, true);
if (!$this->_reindexed) {
// Stop rebuilding the index
$this->getConfig()->set('reindex', 0);
}
function __index($record, $force_flush=false) {
static $queue = array();
if ($record)
$queue[] = $record;
elseif (!$queue)
return;
if (!$force_flush && count($queue) < $this::$BATCH_SIZE)
return true;
foreach ($queue as &$r)
$r = sprintf('(%s)', implode(',', db_input($r)));
unset($r);
$sql = 'INSERT INTO `'.TABLE_PREFIX.'_search` (`object_type`, `object_id`, `title`, `content`)
if (!db_query($sql, false) || count($queue) != db_affected_rows())
throw new Exception('Unable to index content');
$this->_reindexed += count($queue);
if (!--$this->max_batches)
return null;
static function __init() {
self::createSearchTable();
}
Signal::connect('system.install',
array('MysqlSearchBackend', '__init'));
// Saved search system
/**
*
* Fields:
* id - (int:unsigned:auto:pk) unique identifier
* flags - (int:unsigned) flags for this queue
* staff_id - (int:unsigned) Agent to whom this queue belongs (can be null
* for public saved searches)
* title - (text:60) name of the queue
* config - (text) JSON encoded search configuration for the queue
* created - (date) date initially created
* updated - (date:auto_update) time of last update
*/
class SavedSearch extends VerySimpleModel {
static $meta = array(
'pk' => array('id'),
'ordering' => array('sort'),
);
const FLAG_PUBLIC = 0x0001; // Shows up in e'eryone's saved searches
const FLAG_QUEUE = 0x0002; // Shows up in queue navigation
const FLAG_CONTAINER = 0x0004; // Container for other queues ('Open')
const FLAG_INHERIT_CRITERIA = 0x0008; // Include criteria from parent
static function forStaff(Staff $agent) {
return static::objects()->filter(Q::any(array(
'staff_id' => $agent->getId(),
'flags__hasbit' => self::FLAG_PUBLIC,
)))
->exclude(array('flags__hasbit'=>self::FLAG_QUEUE));
function getId() {
return $this->id;
}
function getHref() {
// TODO: Get base page from getRoot();
$root = $this->getRoot();
return 'tickets.php?queue='.$this->getId();
}
function getRoot() {
return 'Ticket';
}
function getPath() {
return $this->path ?: $this->buildPath();
}
function getCriteria() {
if (!isset($this->criteria)) {
$old = @$this->config[0] === '{';
$this->criteria = is_string($this->config)
? JsonDataParser::decode($this->config)
: $this->config;
// Auto-upgrade criteria to new format
if ($old) {
// TODO: Upgrade old ORM path names
$this->criteria = $this->isolateCriteria($this->criteria);
return $this->criteria ?: array();
function getForm($source=null) {
$searchable = $this->getCurrentSearchFields($source);
$fields = array(
':keywords' => new TextboxField(array(
'configuration' => array(
'size' => 40,
'length' => 400,
'classes' => 'full-width headline',
'placeholder' => __('Keywords — Optional'),
),
)),
);
foreach ($searchable as $path=>$field) {
$fields = array_merge($fields, self::getSearchField($field, $path));
// Don't send the state as the souce because it is not in the
// ::parse format (it's in ::to_php format). Instead, source is set
// via ::loadState() below
$form = new AdvancedSearchForm($fields, $source);
$form->addValidator(function($form) {
$selected = 0;
foreach ($form->getFields() as $F) {
if (substr($F->get('name'), -7) == '+search' && $F->getClean())
$selected += 1;
elseif ($F->get('name') == ':keywords' && $F->getClean())
}
if (!$selected)
$form->addError(__('No fields selected for searching'));
// Load state from current configuraiton
foreach ($this->getCriteria() as $I) {
list($path, $method, $value) = $I;
if (!($F = $form->getField("{$path}+search")))
continue;
$F->value = true;
if (!($F = $form->getField("{$path}+method")))
continue;
$F->value = $method;
if ($value && ($F = $form->getField("{$path}+{$method}")))
$F->value = $value;
}
return $form;
}
function getCurrentSearchFields($source=array()) {
static $basic = array(
'Ticket' => array(
'status__state',
'dept_id',
'assignee',
'topic_id',
'created',
'est_duedate',
)
$all = $this->getSupportedMatches();
$core = array();
foreach ($basic[$this->getRoot()] as $path)
if (isset($all[$path]))
$core[$path] = $all[$path];
// Add others from current configuration
foreach ($this->getCriteria() as $C) {
list($path) = $C;
if (isset($all[$path]))
$core[$path] = $all[$path];
if (isset($source['fields']))
foreach ($source['fields'] as $path)
if (isset($all[$path]))
$core[$path] = $all[$path];
return $core;
}
function getSupportedMatches() {
return static::getSearchableFields($this->getRoot());
* Trace ORM fields from a base object and retrieve a complete list of
* fields which can be used in an ORM query based on the base object.
* The base object must implement Searchable interface and extend from
* VerySimpleModel. Then all joins from the object are also inspected,
* and any which implement the Searchable interface are traversed and
* automatically added to the list. The resulting list is cached based
* on the $base class, so multiple calls for the same $base return
* quickly.
*
* Parameters:
* $base - Class, name of a class implementing Searchable
* $recurse - int, number of levels to recurse, default is 2
* $cache - bool, cache results for future class for the same base
* $customData - bool, include all custom data fields for all general
* forms
*/
static function getSearchableFields($base, $recurse=2,
$customData=true, $exclude=array()
static $cache = array(), $otherFields;
if (!in_array('Searchable', class_implements($base)))
return array();
// Early exit if already cached
$fields = &$cache[$base];
if ($fields)
return $fields;
$fields = $fields ?: array();
foreach ($base::getSearchableFields() as $path=>$F) {
if (is_array($F)) {
list($label, $field) = $F;
}
else {
$label = $F->get('label');
$field = $F;
}
$fields[$path] = array($label, $field);
}
if ($customData && $base::supportsCustomData()) {
if (!isset($otherFields)) {
$otherFields = array();
$dfs = DynamicFormField::objects()
->filter(array('form__type' => 'G'))
->select_related('form');
foreach ($dfs as $field) {
$otherFields[$field->getId()] = array($field->form, $field);
}
}
foreach ($otherFields as $id=>$F) {
list($form, $field) = $F;
$label = sprintf("%s / %s",
$form->getTitle(), $field->get('label'));
$fields["entries__answers!{$id}__value"] = array(
$label, $field);
}
}
// Cache the base fields early so that if recursive calls looks for
// this base model, the base fields can be returned without
// requiring recursion.
if ($cache)
$cache[$base] = $fields;
$exclude[$base] = 1;
foreach ($base::getMeta('joins') as $path=>$j) {
$fc = $j['fkey'][0];
if (isset($exclude[$fc]) || $j['list'])
foreach (static::getSearchableFields($fc, $recurse-1,
list($label, $field) = $F;
$fields["{$path}__{$path2}"] = array(
sprintf("%s / %s", $fc, $label),
$field);
if ($cache)
$cache[$base] = $fields;
return $fields;
}
static function getSearchField($F, $name) {
list($label, $field) = $F;
$pieces = array();
$pieces["{$name}+search"] = new BooleanField(array(
'id' => sprintf('%u', crc32($name)) >> 1,
'desc' => $label ?: $field->getLocal('label'),
'classes' => 'inline',
));
$methods = $field->getSearchMethods();
$pieces["{$name}+method"] = new ChoiceField(array(
'choices' => $methods,
'default' => key($methods),
'visibility' => new VisibilityConstraint(new Q(array(
"{$name}+search__eq" => true,
)), VisibilityConstraint::HIDDEN),
));
foreach ($field->getSearchMethodWidgets() as $m=>$w) {
if (!$w)
continue;
list($class, $args) = $w;
$args['required'] = true;
$args['__searchval__'] = true;
$args['visibility'] = new VisibilityConstraint(new Q(array(
"{$name}+method__eq" => $m,
)), VisibilityConstraint::HIDDEN);
$pieces["{$name}+{$m}"] = new $class($args);
}
return $pieces;
}
function getField($path) {
$searchable = $this->getSupportedMatches();
return $searchable[$path];
}
// Remove this and adjust advanced-search-criteria template to use the
// getCriteria() list and getField()
function getSearchFields($form=false) {
$form = $form ?: $this->getForm();
$searchable = $this->getCurrentSearchFields();
foreach ($form->getFields() as $f) {
if (substr($f->get('name'), -7) == '+search') {
$name = substr($f->get('name'), 0, -7);
// Determine the search method and fetch the original field
if (($M = $form->getField("{$name}+method"))
&& ($method = $M->getClean())
&& (list(,$field) = $searchable[$name])
) {
// Request the field to generate a search Q for the
// search method and given value
if ($value = $form->getField("{$name}+{$method}"))
$value = $value->getClean();
}
$info[$name] = array(
'field' => $field,
'method' => $method,
'value' => $value,
'active' => $f->getClean(),
);
}
}
return $info;
}
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Take the criteria from the SavedSearch fields setup and isolate the
* field name being search, the method used for searhing, and the method-
* specific data entered in the UI.
*/
function isolateCriteria($criteria, $root=null) {
$searchable = static::getSearchableFields($root ?: $this->getRoot());
$items = array();
if (!$criteria)
return null;
foreach ($criteria as $k=>$v) {
if (substr($k, -7) === '+method') {
list($name,) = explode('+', $k, 2);
if (!isset($searchable[$name]))
continue;
// Require checkbox to be checked too
if (!$criteria["{$name}+search"])
continue;
// Lookup the field to search this condition
list($label, $field) = $searchable[$name];
// Get the search method and value
$method = $v;
// Not all search methods require a value
$value = $criteria["{$name}+{$method}"];
$items[] = array($name, $method, $value);
}
}
return $items;
}