diff --git a/include/class.orm.php b/include/class.orm.php index 72684b79991dfabeb95678d1c0beb85708ae980f..36f666b5c50ced84087b40a4949c0f6834ee3330 100644 --- a/include/class.orm.php +++ b/include/class.orm.php @@ -673,7 +673,7 @@ class SqlFunction { foreach ($this->args as $A) { $args[] = $this->input($A, $compiler, $model); } - return sprintf('%s(%s)%s', $this->func, implode(',', $args), + return sprintf('%s(%s)%s', $this->func, implode(', ', $args), $alias && $this->alias ? ' AS '.$compiler->quote($this->alias) : ''); } @@ -826,7 +826,7 @@ class SqlCode extends SqlFunction { } function toSql($compiler, $model=false, $alias=false) { - return $this->code; + return $this->code.($alias ? ' AS '.$alias : ''); } } @@ -860,7 +860,7 @@ class SqlAggregate extends SqlFunction { // specification. $E = $this->expr; if ($E instanceof SqlFunction) { - $field = $E->toSql($compiler, $model, $alias); + $field = $E->toSql($compiler, $model); } else { list($field, $rmodel) = $compiler->getField($E, $model, $options); @@ -1175,9 +1175,17 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl return $this; } + function countSelectFields() { + $count = count($this->values) + count($this->annotations); + if (isset($this->extra['select'])) + foreach (@$this->extra['select'] as $S) + $count += count($S); + return $count; + } + function union(QuerySet $other, $all=true) { // Values and values_list _must_ match for this to work - if (count($this->values) != count($other->values)) + if ($this->countSelectFields() != $other->countSelectFields()) throw new OrmException('Union queries must have matching values counts'); // TODO: Clear OFFSET and LIMIT in the $other query @@ -1242,7 +1250,9 @@ class QuerySet implements IteratorAggregate, ArrayAccess, Serializable, Countabl // Load defaults from model $model = $this->model; $query = clone $this; - if (!$options['nosort'] && !$query->ordering && $model::getMeta('ordering')) + if ($options['nosort']) + $query->ordering = array(); + elseif (!$query->ordering && $model::getMeta('ordering')) $query->ordering = $model::getMeta('ordering'); if (false !== $query->related && !$query->values && $model::getMeta('select_related')) $query->related = $model::getMeta('select_related'); @@ -2219,7 +2229,7 @@ class MySqlCompiler extends SqlCompiler { // MySQL doesn't support LIMIT or OFFSET in subqueries. Instead, add // the query as a JOIN and add the join constraint into the WHERE // clause. - elseif ($b instanceof QuerySet && $b->isWindowed()) { + elseif ($b instanceof QuerySet && ($b->isWindowed() || $b->countSelectFields() > 1)) { $f1 = $b->values[0]; $view = $b->asView(); $alias = $this->pushJoin($view, $a, $view, array('constraint'=>array())); @@ -2371,14 +2381,15 @@ class MySqlCompiler extends SqlCompiler { } function compileCount($queryset) { - $model = $queryset->model; - $table = $model::getMeta('table'); - list($where, $having) = $this->getWhereHavingClause($queryset); - $joins = $this->getJoins($queryset); - $sql = 'SELECT COUNT(*) AS count FROM '.$this->quote($table).$joins.$where; - $exec = new MysqlExecutor($sql, $this->params); - $row = $exec->getArray(); - return $row['count']; + $q = clone $queryset; + // Drop extra fields from the queryset + $q->related = $q->anotations = false; + $model = $q->model; + $q->values = $model::getMeta('pk'); + $exec = $q->getQuery(array('nosort' => true)); + $exec->sql = 'SELECT COUNT(*) FROM ('.$exec->sql.') __'; + $row = $exec->getRow(); + return $row ? $row[0] : null; } function compileSelect($queryset) { @@ -2530,6 +2541,8 @@ class MySqlCompiler extends SqlCompiler { foreach ($queryset->extra['select'] as $name=>$expr) { if ($expr instanceof SqlFunction) $expr = $expr->toSql($this, false, $name); + else + $expr = sprintf('%s AS %s', $expr, $this->quote($name)); $fields[] = $expr; } } @@ -2555,6 +2568,9 @@ class MySqlCompiler extends SqlCompiler { $self->params[] = $q->params[$m[1]-1]; return ':'.count($self->params); }, $q->sql); + // Wrap unions in parentheses if they are windowed or sorted + if ($qs->isWindowed() || count($qs->getSortFields())) + $sql = "($sql)"; $unions .= ' UNION '.($all ? 'ALL ' : '').$sql; } } diff --git a/include/class.pagenate.php b/include/class.pagenate.php index 7f4be6a37ec1ff5b0269cccc67b64d3883a7a672..190e0c3d37005a0c3a870bd8ab82790e91b9cb81 100644 --- a/include/class.pagenate.php +++ b/include/class.pagenate.php @@ -83,7 +83,7 @@ class PageNate { } else { $to= $this->total; } - $html=" ".__('Showing')." "; + $html=__('Showing')." "; if ($this->total > 0) { $html .= sprintf(__('%1$d - %2$d of %3$d' /* Used in pagination output */), $start, $end, $this->total); diff --git a/include/class.search.php b/include/class.search.php index a64d216c9a1d6913d441717bbda00c272adcfa72..8965f2e45f7f52856d2ed728d4e6ad0c9c4dac59 100644 --- a/include/class.search.php +++ b/include/class.search.php @@ -329,13 +329,14 @@ class MysqlSearchBackend extends SearchBackend { $criteria = clone $criteria; - $mode = ' IN BOOLEAN MODE'; + $mode = ' IN NATURAL LANGUAGE MODE'; + // If using boolean operators, search in boolean mode + if (preg_match('/["+<>(~-]\w|\w["*)]/u', $query, $T = array())) + $mode = ' IN BOOLEAN MODE'; #if (count(explode(' ', $query)) == 1) # $mode = ' WITH QUERY EXPANSION'; $query = $this->quote($query); - $search = 'MATCH (search.title, search.content) AGAINST (' - .db_input($query) - .$mode.')'; + $search = 'MATCH (Z1.title, Z1.content) AGAINST ('.db_input($query).$mode.')'; $tables = array(); $P = TABLE_PREFIX; $sort = ''; @@ -343,29 +344,18 @@ class MysqlSearchBackend extends SearchBackend { switch ($criteria->model) { case false: case 'TicketModel': - if ($query) { - $key = 'COALESCE(Z1.ticket_id, Z2.ticket_id)'; $criteria->extra(array( 'select' => array( - 'ticket_id' => $key, - 'relevance'=>'`search`.`relevance`', + '__relevance__' => 'Z1.`relevance`', ), - 'order_by' => array(new SqlCode('`relevance`')), 'tables' => array( - "(SELECT object_type, object_id, $search AS `relevance` - FROM `{$P}_search` `search` WHERE $search) `search`", - "(select ticket_id as ticket_id from {$P}ticket - ) Z1 ON (Z1.ticket_id = search.object_id and search.object_type = 'T')", - "(select A3.id as thread_id, A1.ticket_id from {$P}ticket A1 - join {$P}thread A2 on (A1.ticket_id = A2.object_id and A2.object_type = 'T') - join {$P}thread_entry A3 on (A2.id = A3.thread_id) - ) Z2 ON (Z2.thread_id = search.object_id and search.object_type = 'H')", + str_replace(array(':', '{}'), array(TABLE_PREFIX, $search), + "(SELECT COALESCE(Z3.`object_id`, Z5.`ticket_id`) as `ticket_id`, {} 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`) WHERE {}) Z1"), ) )); // XXX: This is extremely ugly - $criteria->filter(array('ticket_id'=>new SqlCode($key))); - $criteria->distinct('ticket_id'); - } + $criteria->filter(array('ticket_id'=>new SqlCode('Z1.`ticket_id`')))->distinct('ticket_id'); + // TODO: Consider sorting preferences } @@ -800,7 +790,7 @@ class SavedSearch extends VerySimpleModel { } return $pieces; } - + /** * Collect information on the search form. * @@ -837,7 +827,7 @@ class SavedSearch extends VerySimpleModel { } return $info; } - + /** * Get a description of a field in a search. Expects an entry from the * array retrieved in ::getSearchFields() @@ -850,7 +840,7 @@ class SavedSearch extends VerySimpleModel { $form = $form ?: $this->getForm(); $searchable = $this->getCurrentSearchFields($form->state); $qs = clone $qs; - + // Figure out fields to search on foreach ($this->getSearchFields($form) as $name=>$info) { if (!$info['active']) @@ -1168,4 +1158,16 @@ class TicketStatusChoiceField extends SelectionField { '!includes' => __('is not'), ); } + + function getSearchQ($method, $value, $name=false) { + $name = $name ?: $this->get('name'); + switch ($method) { + case '!includes': + return Q::not(array("{$name}__in" => array_keys($value))); + case 'includes': + return new Q(array("{$name}__in" => array_keys($value))); + default: + return parent::getSearchQ($method, $value, $name); + } + } } diff --git a/include/staff/tickets.inc.php b/include/staff/tickets.inc.php index 58363036bd02b50c5065ccecf5b141d875b86167..616d3a8a22e9ddbff8a71abe97a41f9c14cbd879 100644 --- a/include/staff/tickets.inc.php +++ b/include/staff/tickets.inc.php @@ -42,7 +42,6 @@ case 'closed': $status='closed'; $results_type=__('Closed Tickets'); $showassigned=true; //closed by. - $tickets->values('staff__firstname', 'staff__lastname', 'team__name'); $queue_sort_options = array('closed', 'priority,due', 'due', 'priority,updated', 'priority,created', 'answered', 'number', 'hot'); break; @@ -91,19 +90,19 @@ case 'search': 'user__emails__address__contains' => $_REQUEST['query'], 'user__org__name__contains' => $_REQUEST['query'], )); + $tickets->filter($basic_search); if (!$_REQUEST['search-type']) { // [Search] click, consider keywords too. This is a // relatively ugly hack. SearchBackend::find() add in a // constraint for the search. We need to pop that off and // include it as an OR with the above constraints - $tickets = $ost->searcher->find($_REQUEST['query'], $tickets); - $keywords = array_pop($tickets->constraints); - $basic_search->add($keywords); - // FIXME: The subquery technique below will crash with - // keyword search - $use_subquery = false; + $keywords = TicketModel::objects(); + $keywords->extra(array('select' => array('ticket_id' => 'Z1.ticket_id'))); + $keywords = $ost->searcher->find($_REQUEST['query'], $keywords); + $tickets->values('ticket_id')->annotate(array('__relevance__' => new SqlCode(0.5))); + $keywords->aggregated = true; // Hack to prevent select ticket.* + $tickets->union($keywords)->order_by(new SqlCode('__relevance__'), QuerySet::DESC); } - $tickets->filter($basic_search); } // Clear sticky search queue unset($_SESSION[$queue_key]); @@ -114,21 +113,12 @@ case 'search': $view_all_tickets = $thisstaff->hasPerm(SearchBackend::PERM_EVERYTHING); $results_type=__('Advanced Search') . '<a class="action-button" href="?clear_filter"><i style="top:0" class="icon-ban-circle"></i> <em>' . __('clear') . '</em></a>'; - $has_relevance = false; - foreach ($tickets->getSortFields() as $sf) { - if ($sf instanceof SqlCode && $sf->code == '`relevance`') { + foreach ($form->getFields() as $sf) { + if ($sf->get('name') == 'keywords' && $sf->getClean()) { $has_relevance = true; break; } } - if ($has_relevance) { - $use_subquery = false; - array_unshift($queue_sort_options, 'relevance'); - } - elseif ($_SESSION[$queue_sort_key] == 'relevance') { - unset($_SESSION[$queue_sort_key]); - } - break; } // Apply user filter @@ -165,8 +155,6 @@ if ($status != 'closed' && $queue_name != 'assigned') { $showassigned = !$hideassigned; if ($status == 'open' && $hideassigned) $tickets->filter(array('staff_id'=>0, 'team_id'=>0)); - else - $tickets->values('staff__firstname', 'staff__lastname', 'team__name'); } // Apply primary ticket status @@ -202,9 +190,29 @@ $pageNav = new Pagenate($count, $page, PAGE_LIMIT); $pageNav->setURL('tickets.php', $args); $tickets = $pageNav->paginate($tickets); +// Rewrite $tickets to use a nested query, which will include the LIMIT part +// in order to speed the result +// +// ATM, advanced search with keywords doesn't support the subquery approach +if ($use_subquery) { + $orig_tickets = clone $tickets; + $tickets2 = TicketModel::objects(); + $tickets2->values = $tickets->values; + $tickets2->filter(array('ticket_id__in' => $tickets->values_flat('ticket_id'))); + + // Transfer the order_by from the original tickets + $tickets2->order_by($orig_tickets->getSortFields()); + $tickets = $tickets2; +} + // Apply requested sorting $queue_sort_key = sprintf(':Q%s:%s:sort', ObjectModel::OBJECT_TYPE_TICKET, $queue_name); +// If relevance is available, use it as the default +if ($has_relevance) { + array_unshift($queue_sort_options, 'relevance'); +} + if (isset($_GET['sort'])) { $_SESSION[$queue_sort_key] = array($_GET['sort'], $_GET['dir']); } @@ -215,6 +223,7 @@ elseif (!isset($_SESSION[$queue_sort_key])) { list($sort_cols, $sort_dir) = $_SESSION[$queue_sort_key]; $orm_dir = $sort_dir ? QuerySet::ASC : QuerySet::DESC; $orm_dir_r = $sort_dir ? QuerySet::DESC : QuerySet::ASC; + switch ($sort_cols) { case 'number': $tickets->extra(array( @@ -267,7 +276,7 @@ case 'hot': break; case 'relevance': - $tickets->order_by(new SqlCode('relevance'), $orm_dir); + $tickets->order_by(new SqlCode('__relevance__'), $orm_dir); break; default: @@ -284,27 +293,11 @@ case 'updated': // Save the query to the session for exporting $_SESSION[':Q:tickets'] = $tickets; -// Rewrite $tickets to use a nested query, which will include the LIMIT part -// in order to speed the result -// -// ATM, advanced search with keywords doesn't support the subquery approach -if ($use_subquery) { - $orig_tickets = clone $tickets; - $tickets2 = TicketModel::objects(); - $tickets2->values = $tickets->values; - $tickets2->filter(array('ticket_id__in' => $tickets->values_flat('ticket_id'))); - - // Transfer the order_by from the original tickets - $tickets2->order_by($tickets->getSortFields()); - $tickets = $tickets2; -} - TicketForm::ensureDynamicDataView(); // Select pertinent columns // ------------------------------------------------------------ -$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__priority__priority_color', 'cdata__priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate', 'isanswered'); - +$tickets->values('lock__staff_id', 'staff_id', 'isoverdue', 'team_id', 'ticket_id', 'number', 'cdata__subject', 'user__default_email__address', 'source', 'cdata__priority__priority_color', 'cdata__priority__priority_desc', 'status_id', 'status__name', 'status__state', 'dept_id', 'dept__name', 'user__name', 'lastupdate', 'isanswered', 'staff__firstname', 'staff__lastname', 'team__name'); // Add in annotations $tickets->annotate(array( 'collab_count' => TicketThread::objects() @@ -341,7 +334,7 @@ return false;"> <input type="hidden" name="search-type" value=""/> <div class="attached input"> <input type="text" class="basic-search" data-url="ajax.php/tickets/lookup" name="query" - autofocus size="30" value="<?php echo Format::htmlchars($_REQUEST['query'], true); ?>" + autofocus size="30" value="<?php echo Format::htmlchars(urldecode($_REQUEST['query']), true); ?>" autocomplete="off" autocorrect="off" autocapitalize="off"> <button type="submit" class="attached button"><i class="icon-search"></i> </button> @@ -360,7 +353,7 @@ return false;"> <div class="pull-left flush-left"> <h2><a href="<?php echo $refresh_url; ?>" title="<?php echo __('Refresh'); ?>"><i class="icon-refresh"></i> <?php echo - $results_type.$showing; ?></a></h2> + $results_type; ?></a></h2> </div> <div class="pull-right flush-right"> <?php