3563 lines
117 KiB
Perl
3563 lines
117 KiB
Perl
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
#
|
|
# This Source Code Form is "Incompatible With Secondary Licenses", as
|
|
# defined by the Mozilla Public License, v. 2.0.
|
|
|
|
package Bugzilla::Search;
|
|
|
|
use 5.10.1;
|
|
use strict;
|
|
use warnings;
|
|
|
|
use parent qw(Exporter);
|
|
@Bugzilla::Search::EXPORT = qw(
|
|
IsValidQueryType
|
|
split_order_term
|
|
);
|
|
|
|
use Bugzilla::Error;
|
|
use Bugzilla::Util;
|
|
use Bugzilla::Constants;
|
|
use Bugzilla::Group;
|
|
use Bugzilla::User;
|
|
use Bugzilla::Field;
|
|
use Bugzilla::Search::Clause;
|
|
use Bugzilla::Search::ClauseGroup;
|
|
use Bugzilla::Search::Condition qw(condition);
|
|
use Bugzilla::Status;
|
|
use Bugzilla::Keyword;
|
|
|
|
use Data::Dumper;
|
|
use Date::Format;
|
|
use Date::Parse;
|
|
use Scalar::Util qw(blessed);
|
|
use List::MoreUtils qw(all firstidx part uniq);
|
|
use POSIX qw(INT_MAX floor);
|
|
use Storable qw(dclone);
|
|
use Time::HiRes qw(gettimeofday tv_interval);
|
|
|
|
# Description Of Boolean Charts
|
|
# -----------------------------
|
|
#
|
|
# A boolean chart is a way of representing the terms in a logical
|
|
# expression. Bugzilla builds SQL queries depending on how you enter
|
|
# terms into the boolean chart. Boolean charts are represented in
|
|
# urls as three-tuples of (chart id, row, column). The query form
|
|
# (query.cgi) may contain an arbitrary number of boolean charts where
|
|
# each chart represents a clause in a SQL query.
|
|
#
|
|
# The query form starts out with one boolean chart containing one
|
|
# row and one column. Extra rows can be created by pressing the
|
|
# AND button at the bottom of the chart. Extra columns are created
|
|
# by pressing the OR button at the right end of the chart. Extra
|
|
# charts are created by pressing "Add another boolean chart".
|
|
#
|
|
# Each chart consists of an arbitrary number of rows and columns.
|
|
# The terms within a row are ORed together. The expressions represented
|
|
# by each row are ANDed together. The expressions represented by each
|
|
# chart are ANDed together.
|
|
#
|
|
# ----------------------
|
|
# | col2 | col2 | col3 |
|
|
# --------------|------|------|
|
|
# | row1 | a1 | a2 | |
|
|
# |------|------|------|------| => ((a1 OR a2) AND (b1 OR b2 OR b3) AND (c1))
|
|
# | row2 | b1 | b2 | b3 |
|
|
# |------|------|------|------|
|
|
# | row3 | c1 | | |
|
|
# -----------------------------
|
|
#
|
|
# --------
|
|
# | col2 |
|
|
# --------------|
|
|
# | row1 | d1 | => (d1)
|
|
# ---------------
|
|
#
|
|
# Together, these two charts represent a SQL expression like this
|
|
# SELECT blah FROM blah WHERE ( (a1 OR a2)AND(b1 OR b2 OR b3)AND(c1)) AND (d1)
|
|
#
|
|
# The terms within a single row of a boolean chart are all constraints
|
|
# on a single piece of data. If you're looking for a bug that has two
|
|
# different people cc'd on it, then you need to use two boolean charts.
|
|
# This will find bugs with one CC matching 'foo@blah.org' and and another
|
|
# CC matching 'bar@blah.org'.
|
|
#
|
|
# --------------------------------------------------------------
|
|
# CC | equal to
|
|
# foo@blah.org
|
|
# --------------------------------------------------------------
|
|
# CC | equal to
|
|
# bar@blah.org
|
|
#
|
|
# If you try to do this query by pressing the AND button in the
|
|
# original boolean chart then what you'll get is an expression that
|
|
# looks for a single CC where the login name is both "foo@blah.org",
|
|
# and "bar@blah.org". This is impossible.
|
|
#
|
|
# --------------------------------------------------------------
|
|
# CC | equal to
|
|
# foo@blah.org
|
|
# AND
|
|
# CC | equal to
|
|
# bar@blah.org
|
|
# --------------------------------------------------------------
|
|
|
|
#############
|
|
# Constants #
|
|
#############
|
|
|
|
# When doing searches, NULL datetimes are treated as this date.
|
|
use constant EMPTY_DATETIME => '1970-01-01 00:00:00';
|
|
use constant EMPTY_DATE => '1970-01-01';
|
|
|
|
# This is the regex for real numbers from Regexp::Common, modified to be
|
|
# more readable.
|
|
use constant NUMBER_REGEX => qr/
|
|
^[+-]? # A sign, optionally.
|
|
|
|
(?=\d|\.) # Then either a digit or "."
|
|
\d* # Followed by many other digits
|
|
(?:
|
|
\. # Followed possibly by some decimal places
|
|
(?:\d*)
|
|
)?
|
|
|
|
(?: # Followed possibly by an exponent.
|
|
[Ee]
|
|
[+-]?
|
|
\d+
|
|
)?
|
|
$
|
|
/x;
|
|
|
|
# If you specify a search type in the boolean charts, this describes
|
|
# which operator maps to which internal function here.
|
|
use constant OPERATORS => {
|
|
equals => \&_simple_operator,
|
|
notequals => \&_simple_operator,
|
|
casesubstring => \&_casesubstring,
|
|
substring => \&_substring,
|
|
substr => \&_substring,
|
|
notsubstring => \&_notsubstring,
|
|
regexp => \&_regexp,
|
|
notregexp => \&_notregexp,
|
|
lessthan => \&_simple_operator,
|
|
lessthaneq => \&_simple_operator,
|
|
matches => sub { ThrowUserError("search_content_without_matches"); },
|
|
notmatches => sub { ThrowUserError("search_content_without_matches"); },
|
|
greaterthan => \&_simple_operator,
|
|
greaterthaneq => \&_simple_operator,
|
|
anyexact => \&_anyexact,
|
|
anywordssubstr => \&_anywordsubstr,
|
|
allwordssubstr => \&_allwordssubstr,
|
|
nowordssubstr => \&_nowordssubstr,
|
|
anywords => \&_anywords,
|
|
allwords => \&_allwords,
|
|
nowords => \&_nowords,
|
|
changedbefore => \&_changedbefore_changedafter,
|
|
changedafter => \&_changedbefore_changedafter,
|
|
changedfrom => \&_changedfrom_changedto,
|
|
changedto => \&_changedfrom_changedto,
|
|
changedby => \&_changedby,
|
|
isempty => \&_isempty,
|
|
isnotempty => \&_isnotempty,
|
|
};
|
|
|
|
# Some operators are really just standard SQL operators, and are
|
|
# all implemented by the _simple_operator function, which uses this
|
|
# constant.
|
|
use constant SIMPLE_OPERATORS => {
|
|
equals => '=',
|
|
notequals => '!=',
|
|
greaterthan => '>',
|
|
greaterthaneq => '>=',
|
|
lessthan => '<',
|
|
lessthaneq => "<=",
|
|
};
|
|
|
|
# Most operators just reverse by removing or adding "not" from/to them.
|
|
# However, some operators reverse in a different way, so those are listed
|
|
# here.
|
|
use constant OPERATOR_REVERSE => {
|
|
nowords => 'anywords',
|
|
nowordssubstr => 'anywordssubstr',
|
|
anywords => 'nowords',
|
|
anywordssubstr => 'nowordssubstr',
|
|
lessthan => 'greaterthaneq',
|
|
lessthaneq => 'greaterthan',
|
|
greaterthan => 'lessthaneq',
|
|
greaterthaneq => 'lessthan',
|
|
isempty => 'isnotempty',
|
|
isnotempty => 'isempty',
|
|
# The following don't currently have reversals:
|
|
# casesubstring, anyexact, allwords, allwordssubstr
|
|
};
|
|
|
|
# For these operators, even if a field is numeric (is_numeric returns true),
|
|
# we won't treat the input like a number.
|
|
use constant NON_NUMERIC_OPERATORS => qw(
|
|
changedafter
|
|
changedbefore
|
|
changedfrom
|
|
changedto
|
|
regexp
|
|
notregexp
|
|
);
|
|
|
|
# These operators ignore the entered value
|
|
use constant NO_VALUE_OPERATORS => qw(
|
|
isempty
|
|
isnotempty
|
|
);
|
|
|
|
use constant MULTI_SELECT_OVERRIDE => {
|
|
notequals => \&_multiselect_negative,
|
|
notregexp => \&_multiselect_negative,
|
|
notsubstring => \&_multiselect_negative,
|
|
nowords => \&_multiselect_negative,
|
|
nowordssubstr => \&_multiselect_negative,
|
|
|
|
allwords => \&_multiselect_multiple,
|
|
allwordssubstr => \&_multiselect_multiple,
|
|
anyexact => \&_multiselect_multiple,
|
|
anywords => \&_multiselect_multiple,
|
|
anywordssubstr => \&_multiselect_multiple,
|
|
|
|
_non_changed => \&_multiselect_nonchanged,
|
|
};
|
|
|
|
use constant OPERATOR_FIELD_OVERRIDE => {
|
|
# User fields
|
|
'attachments.submitter' => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
assigned_to => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
assigned_to_realname => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
cc => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
commenter => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
reporter => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
reporter_realname => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
'requestees.login_name' => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
'setters.login_name' => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
qa_contact => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
qa_contact_realname => {
|
|
_non_changed => \&_user_nonchanged,
|
|
},
|
|
|
|
# General Bug Fields
|
|
alias => { _non_changed => \&_alias_nonchanged },
|
|
'attach_data.thedata' => MULTI_SELECT_OVERRIDE,
|
|
# We check all attachment fields against this.
|
|
attachments => MULTI_SELECT_OVERRIDE,
|
|
blocked => MULTI_SELECT_OVERRIDE,
|
|
bug_file_loc => { _non_changed => \&_nullable },
|
|
bug_group => MULTI_SELECT_OVERRIDE,
|
|
classification => {
|
|
_non_changed => \&_classification_nonchanged,
|
|
},
|
|
component => {
|
|
_non_changed => \&_component_nonchanged,
|
|
},
|
|
content => {
|
|
matches => \&_content_matches,
|
|
notmatches => \&_content_matches,
|
|
_default => sub { ThrowUserError("search_content_without_matches"); },
|
|
},
|
|
days_elapsed => {
|
|
_default => \&_days_elapsed,
|
|
},
|
|
dependson => MULTI_SELECT_OVERRIDE,
|
|
keywords => MULTI_SELECT_OVERRIDE,
|
|
'flagtypes.name' => {
|
|
_non_changed => \&_flagtypes_nonchanged,
|
|
},
|
|
longdesc => {
|
|
changedby => \&_long_desc_changedby,
|
|
changedbefore => \&_long_desc_changedbefore_after,
|
|
changedafter => \&_long_desc_changedbefore_after,
|
|
_non_changed => \&_long_desc_nonchanged,
|
|
},
|
|
'longdescs.count' => {
|
|
changedby => \&_long_desc_changedby,
|
|
changedbefore => \&_long_desc_changedbefore_after,
|
|
changedafter => \&_long_desc_changedbefore_after,
|
|
changedfrom => \&_invalid_combination,
|
|
changedto => \&_invalid_combination,
|
|
_default => \&_long_descs_count,
|
|
},
|
|
'longdescs.isprivate' => MULTI_SELECT_OVERRIDE,
|
|
owner_idle_time => {
|
|
greaterthan => \&_owner_idle_time_greater_less,
|
|
greaterthaneq => \&_owner_idle_time_greater_less,
|
|
lessthan => \&_owner_idle_time_greater_less,
|
|
lessthaneq => \&_owner_idle_time_greater_less,
|
|
_default => \&_invalid_combination,
|
|
},
|
|
product => {
|
|
_non_changed => \&_product_nonchanged,
|
|
},
|
|
tag => MULTI_SELECT_OVERRIDE,
|
|
comment_tag => MULTI_SELECT_OVERRIDE,
|
|
|
|
# Timetracking Fields
|
|
deadline => { _non_changed => \&_deadline },
|
|
percentage_complete => {
|
|
_non_changed => \&_percentage_complete,
|
|
},
|
|
work_time => {
|
|
changedby => \&_work_time_changedby,
|
|
changedbefore => \&_work_time_changedbefore_after,
|
|
changedafter => \&_work_time_changedbefore_after,
|
|
_default => \&_work_time,
|
|
},
|
|
last_visit_ts => {
|
|
_non_changed => \&_last_visit_ts,
|
|
_default => \&_last_visit_ts_invalid_operator,
|
|
},
|
|
|
|
# Custom Fields
|
|
FIELD_TYPE_FREETEXT, { _non_changed => \&_nullable },
|
|
FIELD_TYPE_BUG_ID, { _non_changed => \&_nullable_int },
|
|
FIELD_TYPE_DATETIME, { _non_changed => \&_nullable_datetime },
|
|
FIELD_TYPE_DATE, { _non_changed => \&_nullable_date },
|
|
FIELD_TYPE_TEXTAREA, { _non_changed => \&_nullable },
|
|
FIELD_TYPE_MULTI_SELECT, MULTI_SELECT_OVERRIDE,
|
|
FIELD_TYPE_BUG_URLS, MULTI_SELECT_OVERRIDE,
|
|
};
|
|
|
|
# These are fields where special action is taken depending on the
|
|
# *value* passed in to the chart, sometimes.
|
|
# This is a sub because custom fields are dynamic
|
|
sub SPECIAL_PARSING {
|
|
my $map = {
|
|
# Pronoun Fields (Ones that can accept %user%, etc.)
|
|
assigned_to => \&_contact_pronoun,
|
|
cc => \&_contact_pronoun,
|
|
commenter => \&_contact_pronoun,
|
|
qa_contact => \&_contact_pronoun,
|
|
reporter => \&_contact_pronoun,
|
|
'setters.login_name' => \&_contact_pronoun,
|
|
'requestees.login_name' => \&_contact_pronoun,
|
|
|
|
# Date Fields that accept the 1d, 1w, 1m, 1y, etc. format.
|
|
creation_ts => \&_datetime_translate,
|
|
deadline => \&_date_translate,
|
|
delta_ts => \&_datetime_translate,
|
|
|
|
# last_visit field that accept both a 1d, 1w, 1m, 1y format and the
|
|
# %last_changed% pronoun.
|
|
last_visit_ts => \&_last_visit_datetime,
|
|
};
|
|
foreach my $field (Bugzilla->active_custom_fields) {
|
|
if ($field->type == FIELD_TYPE_DATETIME) {
|
|
$map->{$field->name} = \&_datetime_translate;
|
|
} elsif ($field->type == FIELD_TYPE_DATE) {
|
|
$map->{$field->name} = \&_date_translate;
|
|
}
|
|
}
|
|
return $map;
|
|
};
|
|
|
|
# Information about fields that represent "users", used by _user_nonchanged.
|
|
# There are other user fields than the ones listed here, but those use
|
|
# defaults in _user_nonchanged.
|
|
use constant USER_FIELDS => {
|
|
'attachments.submitter' => {
|
|
field => 'submitter_id',
|
|
join => { table => 'attachments' },
|
|
isprivate => 1,
|
|
},
|
|
cc => {
|
|
field => 'who',
|
|
join => { table => 'cc' },
|
|
},
|
|
commenter => {
|
|
field => 'who',
|
|
join => { table => 'longdescs', join => 'INNER' },
|
|
isprivate => 1,
|
|
},
|
|
qa_contact => {
|
|
nullable => 1,
|
|
},
|
|
'requestees.login_name' => {
|
|
nullable => 1,
|
|
field => 'requestee_id',
|
|
join => { table => 'flags' },
|
|
},
|
|
'setters.login_name' => {
|
|
field => 'setter_id',
|
|
join => { table => 'flags' },
|
|
},
|
|
};
|
|
|
|
# Backwards compatibility for times that we changed the names of fields
|
|
# or URL parameters.
|
|
use constant FIELD_MAP => {
|
|
'attachments.thedata' => 'attach_data.thedata',
|
|
bugidtype => 'bug_id_type',
|
|
changedin => 'days_elapsed',
|
|
long_desc => 'longdesc',
|
|
tags => 'tag',
|
|
};
|
|
|
|
# Some fields are not sorted on themselves, but on other fields.
|
|
# We need to have a list of these fields and what they map to.
|
|
use constant SPECIAL_ORDER => {
|
|
'target_milestone' => {
|
|
order => ['map_target_milestone.sortkey','map_target_milestone.value'],
|
|
join => {
|
|
table => 'milestones',
|
|
from => 'target_milestone',
|
|
to => 'value',
|
|
extra => ['bugs.product_id = map_target_milestone.product_id'],
|
|
join => 'INNER',
|
|
}
|
|
},
|
|
};
|
|
|
|
# Certain columns require other columns to come before them
|
|
# in _select_columns, and should be put there if they're not there.
|
|
use constant COLUMN_DEPENDS => {
|
|
classification => ['product'],
|
|
percentage_complete => ['actual_time', 'remaining_time'],
|
|
};
|
|
|
|
# This describes tables that must be joined when you want to display
|
|
# certain columns in the buglist. For the most part, Search.pm uses
|
|
# DB::Schema to figure out what needs to be joined, but for some
|
|
# fields it needs a little help.
|
|
sub COLUMN_JOINS {
|
|
my $invocant = shift;
|
|
my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
|
|
|
|
my $joins = {
|
|
actual_time => {
|
|
table => '(SELECT bug_id, SUM(work_time) AS total'
|
|
. ' FROM longdescs GROUP BY bug_id)',
|
|
join => 'INNER',
|
|
},
|
|
alias => {
|
|
table => 'bugs_aliases',
|
|
as => 'map_alias',
|
|
},
|
|
assigned_to => {
|
|
from => 'assigned_to',
|
|
to => 'userid',
|
|
table => 'profiles',
|
|
join => 'INNER',
|
|
},
|
|
reporter => {
|
|
from => 'reporter',
|
|
to => 'userid',
|
|
table => 'profiles',
|
|
join => 'INNER',
|
|
},
|
|
qa_contact => {
|
|
from => 'qa_contact',
|
|
to => 'userid',
|
|
table => 'profiles',
|
|
},
|
|
component => {
|
|
from => 'component_id',
|
|
to => 'id',
|
|
table => 'components',
|
|
join => 'INNER',
|
|
},
|
|
product => {
|
|
from => 'product_id',
|
|
to => 'id',
|
|
table => 'products',
|
|
join => 'INNER',
|
|
},
|
|
classification => {
|
|
table => 'classifications',
|
|
from => 'map_product.classification_id',
|
|
to => 'id',
|
|
join => 'INNER',
|
|
},
|
|
'flagtypes.name' => {
|
|
as => 'map_flags',
|
|
table => 'flags',
|
|
extra => ['map_flags.attach_id IS NULL'],
|
|
then_to => {
|
|
as => 'map_flagtypes',
|
|
table => 'flagtypes',
|
|
from => 'map_flags.type_id',
|
|
to => 'id',
|
|
},
|
|
},
|
|
keywords => {
|
|
table => 'keywords',
|
|
then_to => {
|
|
as => 'map_keyworddefs',
|
|
table => 'keyworddefs',
|
|
from => 'map_keywords.keywordid',
|
|
to => 'id',
|
|
},
|
|
},
|
|
blocked => {
|
|
table => 'dependencies',
|
|
to => 'dependson',
|
|
},
|
|
dependson => {
|
|
table => 'dependencies',
|
|
to => 'blocked',
|
|
},
|
|
'longdescs.count' => {
|
|
table => 'longdescs',
|
|
join => 'INNER',
|
|
},
|
|
tag => {
|
|
as => 'map_bug_tag',
|
|
table => 'bug_tag',
|
|
then_to => {
|
|
as => 'map_tag',
|
|
table => 'tag',
|
|
extra => ['map_tag.user_id = ' . $user->id],
|
|
from => 'map_bug_tag.tag_id',
|
|
to => 'id',
|
|
},
|
|
},
|
|
last_visit_ts => {
|
|
as => 'bug_user_last_visit',
|
|
table => 'bug_user_last_visit',
|
|
extra => ['bug_user_last_visit.user_id = ' . $user->id],
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
},
|
|
};
|
|
return $joins;
|
|
};
|
|
|
|
# This constant defines the columns that can be selected in a query
|
|
# and/or displayed in a bug list. Column records include the following
|
|
# fields:
|
|
#
|
|
# 1. id: a unique identifier by which the column is referred in code;
|
|
#
|
|
# 2. name: The name of the column in the database (may also be an expression
|
|
# that returns the value of the column);
|
|
#
|
|
# 3. title: The title of the column as displayed to users.
|
|
#
|
|
# Note: There are a few hacks in the code that deviate from these definitions.
|
|
# In particular, the redundant short_desc column is removed when the
|
|
# client requests "all" columns.
|
|
#
|
|
# This is really a constant--that is, once it's been called once, the value
|
|
# will always be the same unless somebody adds a new custom field. But
|
|
# we have to do a lot of work inside the subroutine to get the data,
|
|
# and we don't want it to happen at compile time, so we have it as a
|
|
# subroutine.
|
|
sub COLUMNS {
|
|
my $invocant = shift;
|
|
my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
|
|
my $dbh = Bugzilla->dbh;
|
|
my $cache = Bugzilla->request_cache;
|
|
|
|
if (defined $cache->{search_columns}->{$user->id}) {
|
|
return $cache->{search_columns}->{$user->id};
|
|
}
|
|
|
|
# These are columns that don't exist in fielddefs, but are valid buglist
|
|
# columns. (Also see near the bottom of this function for the definition
|
|
# of short_short_desc.)
|
|
my %columns = (
|
|
relevance => { title => 'Relevance' },
|
|
);
|
|
|
|
# Next we define columns that have special SQL instead of just something
|
|
# like "bugs.bug_id".
|
|
my $total_time = "(map_actual_time.total + bugs.remaining_time)";
|
|
my %special_sql = (
|
|
alias => $dbh->sql_group_concat('DISTINCT map_alias.alias'),
|
|
deadline => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'),
|
|
actual_time => 'map_actual_time.total',
|
|
|
|
# "FLOOR" is in there to turn this into an integer, making searches
|
|
# totally predictable. Otherwise you get floating-point numbers that
|
|
# are rather hard to search reliably if you're asking for exact
|
|
# numbers.
|
|
percentage_complete =>
|
|
"(CASE WHEN $total_time = 0"
|
|
. " THEN 0"
|
|
. " ELSE FLOOR(100 * (map_actual_time.total / $total_time))"
|
|
. " END)",
|
|
|
|
'flagtypes.name' => $dbh->sql_group_concat('DISTINCT '
|
|
. $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status'),
|
|
undef, undef, 'map_flagtypes.sortkey, map_flagtypes.name'),
|
|
|
|
'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'),
|
|
|
|
blocked => $dbh->sql_group_concat('DISTINCT map_blocked.blocked'),
|
|
dependson => $dbh->sql_group_concat('DISTINCT map_dependson.dependson'),
|
|
|
|
'longdescs.count' => 'COUNT(DISTINCT map_longdescs_count.comment_id)',
|
|
|
|
tag => $dbh->sql_group_concat('DISTINCT map_tag.name'),
|
|
last_visit_ts => 'bug_user_last_visit.last_visit_ts',
|
|
);
|
|
|
|
# Backward-compatibility for old field names. Goes new_name => old_name.
|
|
# These are here and not in _translate_old_column because the rest of the
|
|
# code actually still uses the old names, while the fielddefs table uses
|
|
# the new names (which is not the case for the fields handled by
|
|
# _translate_old_column).
|
|
my %old_names = (
|
|
creation_ts => 'opendate',
|
|
delta_ts => 'changeddate',
|
|
work_time => 'actual_time',
|
|
);
|
|
|
|
# Fields that are email addresses
|
|
my @email_fields = qw(assigned_to reporter qa_contact);
|
|
# Other fields that are stored in the bugs table as an id, but
|
|
# should be displayed using their name.
|
|
my @id_fields = qw(product component classification);
|
|
|
|
foreach my $col (@email_fields) {
|
|
my $sql = "map_${col}.login_name";
|
|
if (!$user->id) {
|
|
$sql = $dbh->sql_string_until($sql, $dbh->quote('@'));
|
|
}
|
|
$special_sql{$col} = $sql;
|
|
$special_sql{"${col}_realname"} = "map_${col}.realname";
|
|
}
|
|
|
|
foreach my $col (@id_fields) {
|
|
$special_sql{$col} = "map_${col}.name";
|
|
}
|
|
|
|
# Do the actual column-getting from fielddefs, now.
|
|
my @fields = @{ Bugzilla->fields({ obsolete => 0, buglist => 1 }) };
|
|
foreach my $field (@fields) {
|
|
my $id = $field->name;
|
|
$id = $old_names{$id} if exists $old_names{$id};
|
|
my $sql;
|
|
if (exists $special_sql{$id}) {
|
|
$sql = $special_sql{$id};
|
|
}
|
|
elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
|
|
$sql = $dbh->sql_group_concat(
|
|
'DISTINCT map_' . $field->name . '.value');
|
|
}
|
|
else {
|
|
$sql = 'bugs.' . $field->name;
|
|
}
|
|
$columns{$id} = { name => $sql, title => $field->description };
|
|
}
|
|
|
|
# The short_short_desc column is identical to short_desc
|
|
$columns{'short_short_desc'} = $columns{'short_desc'};
|
|
|
|
Bugzilla::Hook::process('buglist_columns', { columns => \%columns });
|
|
|
|
$cache->{search_columns}->{$user->id} = \%columns;
|
|
return $cache->{search_columns}->{$user->id};
|
|
}
|
|
|
|
sub REPORT_COLUMNS {
|
|
my $invocant = shift;
|
|
my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
|
|
|
|
my $columns = dclone(blessed($invocant) ? $invocant->COLUMNS : COLUMNS);
|
|
# There's no reason to support reporting on unique fields.
|
|
# Also, some other fields don't make very good reporting axises,
|
|
# or simply don't work with the current reporting system.
|
|
my @no_report_columns =
|
|
qw(bug_id alias short_short_desc opendate changeddate
|
|
flagtypes.name relevance);
|
|
|
|
# If you're not a time-tracker, you can't use time-tracking
|
|
# columns.
|
|
if (!$user->is_timetracker) {
|
|
push(@no_report_columns, TIMETRACKING_FIELDS);
|
|
}
|
|
|
|
foreach my $name (@no_report_columns) {
|
|
delete $columns->{$name};
|
|
}
|
|
return $columns;
|
|
}
|
|
|
|
# These are fields that never go into the GROUP BY on any DB. bug_id
|
|
# is here because it *always* goes into the GROUP BY as the first item,
|
|
# so it should be skipped when determining extra GROUP BY columns.
|
|
use constant GROUP_BY_SKIP => qw(
|
|
alias
|
|
blocked
|
|
bug_id
|
|
dependson
|
|
flagtypes.name
|
|
keywords
|
|
longdescs.count
|
|
percentage_complete
|
|
tag
|
|
);
|
|
|
|
###############
|
|
# Constructor #
|
|
###############
|
|
|
|
# Note that the params argument may be modified by Bugzilla::Search
|
|
sub new {
|
|
my $invocant = shift;
|
|
my $class = ref($invocant) || $invocant;
|
|
|
|
my $self = { @_ };
|
|
bless($self, $class);
|
|
$self->{'user'} ||= Bugzilla->user;
|
|
|
|
# There are certain behaviors of the CGI "Vars" hash that we don't want.
|
|
# In particular, if you put a single-value arrayref into it, later you
|
|
# get back out a string, which breaks anyexact charts (because they
|
|
# need arrays even for individual items, or we will re-trigger bug 67036).
|
|
#
|
|
# We can't just untie the hash--that would give us a hash with no values.
|
|
# We have to manually copy the hash into a new one, and we have to always
|
|
# do it, because there's no way to know if we were passed a tied hash
|
|
# or not.
|
|
my $params_in = $self->_params;
|
|
my %params = map { $_ => $params_in->{$_} } keys %$params_in;
|
|
$self->{params} = \%params;
|
|
|
|
return $self;
|
|
}
|
|
|
|
|
|
####################
|
|
# Public Accessors #
|
|
####################
|
|
|
|
sub data {
|
|
my $self = shift;
|
|
return $self->{data} if $self->{data};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
# If all fields belong to the 'bugs' table, there is no need to split
|
|
# the original query into two pieces. Else we override the 'fields'
|
|
# argument to first get bug IDs based on the search criteria defined
|
|
# by the caller, and the desired fields are collected in the 2nd query.
|
|
my @orig_fields = $self->_input_columns;
|
|
my $all_in_bugs_table = 1;
|
|
foreach my $field (@orig_fields) {
|
|
next if ($self->COLUMNS->{$field}->{name} // $field) =~ /^bugs\.\w+$/;
|
|
$self->{fields} = ['bug_id'];
|
|
$all_in_bugs_table = 0;
|
|
last;
|
|
}
|
|
|
|
my $start_time = [gettimeofday()];
|
|
my $sql = $self->_sql;
|
|
# Do we just want bug IDs to pass to the 2nd query or all the data immediately?
|
|
my $func = $all_in_bugs_table ? 'selectall_arrayref' : 'selectcol_arrayref';
|
|
my $bug_ids = $dbh->$func($sql);
|
|
my @extra_data = ({sql => $sql, time => tv_interval($start_time)});
|
|
# Restore the original 'fields' argument, just in case.
|
|
$self->{fields} = \@orig_fields unless $all_in_bugs_table;
|
|
|
|
# If there are no bugs found, or all fields are in the 'bugs' table,
|
|
# there is no need for another query.
|
|
if (!scalar @$bug_ids || $all_in_bugs_table) {
|
|
$self->{data} = $bug_ids;
|
|
return wantarray ? ($self->{data}, \@extra_data) : $self->{data};
|
|
}
|
|
|
|
# Make sure the bug_id will be returned. If not, append it to the list.
|
|
my $pos = firstidx { $_ eq 'bug_id' } @orig_fields;
|
|
if ($pos < 0) {
|
|
push(@orig_fields, 'bug_id');
|
|
$pos = $#orig_fields;
|
|
}
|
|
|
|
# Now create a query with the buglist above as the single criteria
|
|
# and the fields that the caller wants. No need to redo security checks;
|
|
# the list has already been validated above.
|
|
my $search = $self->new('fields' => \@orig_fields,
|
|
'params' => {bug_id => $bug_ids, bug_id_type => 'anyexact'},
|
|
'sharer' => $self->_sharer_id,
|
|
'user' => $self->_user,
|
|
'allow_unlimited' => 1,
|
|
'_no_security_check' => 1);
|
|
|
|
$start_time = [gettimeofday()];
|
|
$sql = $search->_sql;
|
|
my $unsorted_data = $dbh->selectall_arrayref($sql);
|
|
push(@extra_data, {sql => $sql, time => tv_interval($start_time)});
|
|
# Let's sort the data. We didn't do it in the query itself because
|
|
# we already know in which order to sort bugs thanks to the first query,
|
|
# and this avoids additional table joins in the SQL query.
|
|
my %data = map { $_->[$pos] => $_ } @$unsorted_data;
|
|
$self->{data} = [map { $data{$_} } @$bug_ids];
|
|
return wantarray ? ($self->{data}, \@extra_data) : $self->{data};
|
|
}
|
|
|
|
sub _sql {
|
|
my ($self) = @_;
|
|
return $self->{sql} if $self->{sql};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my ($joins, $clause) = $self->_charts_to_conditions();
|
|
|
|
if (!$clause->as_string
|
|
&& !Bugzilla->params->{'search_allow_no_criteria'}
|
|
&& !$self->{allow_unlimited})
|
|
{
|
|
ThrowUserError('buglist_parameters_required');
|
|
}
|
|
|
|
my $select = join(', ', $self->_sql_select);
|
|
my $from = $self->_sql_from($joins);
|
|
my $where = $self->_sql_where($clause);
|
|
my $group_by = $dbh->sql_group_by($self->_sql_group_by);
|
|
my $order_by = $self->_sql_order_by
|
|
? "\nORDER BY " . join(', ', $self->_sql_order_by) : '';
|
|
my $limit = $self->_sql_limit;
|
|
$limit = "\n$limit" if $limit;
|
|
|
|
my $query = <<END;
|
|
SELECT $select
|
|
FROM $from
|
|
WHERE $where
|
|
$group_by$order_by$limit
|
|
END
|
|
$self->{sql} = $query;
|
|
return $self->{sql};
|
|
}
|
|
|
|
sub search_description {
|
|
my ($self, $params) = @_;
|
|
my $desc = $self->{'search_description'} ||= [];
|
|
if ($params) {
|
|
push(@$desc, $params);
|
|
}
|
|
# Make sure that the description has actually been generated if
|
|
# people are asking for the whole thing.
|
|
else {
|
|
$self->_sql;
|
|
}
|
|
return $self->{'search_description'};
|
|
}
|
|
|
|
sub boolean_charts_to_custom_search {
|
|
my ($self, $cgi_buffer) = @_;
|
|
my $boolean_charts = $self->_boolean_charts;
|
|
my @as_params = $boolean_charts ? $boolean_charts->as_params : ();
|
|
|
|
# We need to start our new ids after the last custom search "f" id.
|
|
# We can just pick the last id in the array because they are sorted
|
|
# numerically.
|
|
my $last_id = ($self->_field_ids)[-1];
|
|
my $count = defined($last_id) ? $last_id + 1 : 0;
|
|
foreach my $param_set (@as_params) {
|
|
foreach my $name (keys %$param_set) {
|
|
my $value = $param_set->{$name};
|
|
next if !defined $value;
|
|
$cgi_buffer->param($name . $count, $value);
|
|
}
|
|
$count++;
|
|
}
|
|
}
|
|
|
|
sub invalid_order_columns {
|
|
my ($self) = @_;
|
|
my @invalid_columns;
|
|
foreach my $order ($self->_input_order) {
|
|
next if defined $self->_validate_order_column($order);
|
|
push(@invalid_columns, $order);
|
|
}
|
|
return \@invalid_columns;
|
|
}
|
|
|
|
sub order {
|
|
my ($self) = @_;
|
|
return $self->_valid_order;
|
|
}
|
|
|
|
######################
|
|
# Internal Accessors #
|
|
######################
|
|
|
|
# Fields that are legal for boolean charts of any kind.
|
|
sub _chart_fields {
|
|
my ($self) = @_;
|
|
|
|
if (!$self->{chart_fields}) {
|
|
my $chart_fields = Bugzilla->fields({ by_name => 1 });
|
|
|
|
if (!$self->_user->is_timetracker) {
|
|
foreach my $tt_field (TIMETRACKING_FIELDS) {
|
|
delete $chart_fields->{$tt_field};
|
|
}
|
|
}
|
|
$self->{chart_fields} = $chart_fields;
|
|
}
|
|
return $self->{chart_fields};
|
|
}
|
|
|
|
# There are various places in Search.pm that we need to know the list of
|
|
# valid multi-select fields--or really, fields that are stored like
|
|
# multi-selects, which includes BUG_URLS fields.
|
|
sub _multi_select_fields {
|
|
my ($self) = @_;
|
|
$self->{multi_select_fields} ||= Bugzilla->fields({
|
|
by_name => 1,
|
|
type => [FIELD_TYPE_MULTI_SELECT, FIELD_TYPE_BUG_URLS]});
|
|
return $self->{multi_select_fields};
|
|
}
|
|
|
|
# $self->{params} contains values that could be undef, could be a string,
|
|
# or could be an arrayref. Sometimes we want that value as an array,
|
|
# always.
|
|
sub _param_array {
|
|
my ($self, $name) = @_;
|
|
my $value = $self->_params->{$name};
|
|
if (!defined $value) {
|
|
return ();
|
|
}
|
|
if (ref($value) eq 'ARRAY') {
|
|
return @$value;
|
|
}
|
|
return ($value);
|
|
}
|
|
|
|
sub _params { $_[0]->{params} }
|
|
sub _user { return $_[0]->{user} }
|
|
sub _sharer_id { $_[0]->{sharer} }
|
|
|
|
##############################
|
|
# Internal Accessors: SELECT #
|
|
##############################
|
|
|
|
# These are the fields the user has chosen to display on the buglist,
|
|
# exactly as they were passed to new().
|
|
sub _input_columns { @{ $_[0]->{'fields'} || [] } }
|
|
|
|
# These are columns that are also going to be in the SELECT for one reason
|
|
# or another, but weren't actually requested by the caller.
|
|
sub _extra_columns {
|
|
my ($self) = @_;
|
|
# Everything that's going to be in the ORDER BY must also be
|
|
# in the SELECT.
|
|
push(@{ $self->{extra_columns} }, $self->_valid_order_columns);
|
|
return @{ $self->{extra_columns} };
|
|
}
|
|
|
|
# For search functions to modify extra_columns. It doesn't matter if
|
|
# people push the same column onto this array multiple times, because
|
|
# _select_columns will call "uniq" on its final result.
|
|
sub _add_extra_column {
|
|
my ($self, $column) = @_;
|
|
push(@{ $self->{extra_columns} }, $column);
|
|
}
|
|
|
|
# These are the columns that we're going to be actually SELECTing.
|
|
sub _display_columns {
|
|
my ($self) = @_;
|
|
return @{ $self->{display_columns} } if $self->{display_columns};
|
|
|
|
# Do not alter the list from _input_columns at all, even if there are
|
|
# duplicated columns. Those are passed by the caller, and the caller
|
|
# expects to get them back in the exact same order.
|
|
my @columns = $self->_input_columns;
|
|
|
|
# Only add columns which are not already listed.
|
|
my %list = map { $_ => 1 } @columns;
|
|
foreach my $column ($self->_extra_columns) {
|
|
push(@columns, $column) unless $list{$column}++;
|
|
}
|
|
$self->{display_columns} = \@columns;
|
|
return @{ $self->{display_columns} };
|
|
}
|
|
|
|
# These are the columns that are involved in the query.
|
|
sub _select_columns {
|
|
my ($self) = @_;
|
|
return @{ $self->{select_columns} } if $self->{select_columns};
|
|
|
|
my @select_columns;
|
|
foreach my $column ($self->_display_columns) {
|
|
if (my $add_first = COLUMN_DEPENDS->{$column}) {
|
|
push(@select_columns, @$add_first);
|
|
}
|
|
push(@select_columns, $column);
|
|
}
|
|
# Remove duplicated columns.
|
|
$self->{select_columns} = [uniq @select_columns];
|
|
return @{ $self->{select_columns} };
|
|
}
|
|
|
|
# This takes _display_columns and translates it into the actual SQL that
|
|
# will go into the SELECT clause.
|
|
sub _sql_select {
|
|
my ($self) = @_;
|
|
my @sql_fields;
|
|
foreach my $column ($self->_display_columns) {
|
|
my $sql = $self->COLUMNS->{$column}->{name} // '';
|
|
if ($sql) {
|
|
my $alias = $column;
|
|
# Aliases cannot contain dots in them. We convert them to underscores.
|
|
$alias =~ tr/./_/;
|
|
$sql .= " AS $alias";
|
|
}
|
|
else {
|
|
$sql = $column;
|
|
}
|
|
push(@sql_fields, $sql);
|
|
}
|
|
return @sql_fields;
|
|
}
|
|
|
|
################################
|
|
# Internal Accessors: ORDER BY #
|
|
################################
|
|
|
|
# The "order" that was requested by the consumer, exactly as it was
|
|
# requested.
|
|
sub _input_order { @{ $_[0]->{'order'} || [] } }
|
|
# Requested order with invalid values removed and old names translated
|
|
sub _valid_order {
|
|
my ($self) = @_;
|
|
return map { ($self->_validate_order_column($_)) } $self->_input_order;
|
|
}
|
|
# The valid order with just the column names, and no ASC or DESC.
|
|
sub _valid_order_columns {
|
|
my ($self) = @_;
|
|
return map { (split_order_term($_))[0] } $self->_valid_order;
|
|
}
|
|
|
|
sub _validate_order_column {
|
|
my ($self, $order_item) = @_;
|
|
|
|
# Translate old column names
|
|
my ($field, $direction) = split_order_term($order_item);
|
|
$field = $self->_translate_old_column($field);
|
|
|
|
# Only accept valid columns
|
|
return if (!exists $self->COLUMNS->{$field});
|
|
|
|
# Relevance column can be used only with one or more fulltext searches
|
|
return if ($field eq 'relevance' && !$self->COLUMNS->{$field}->{name});
|
|
|
|
$direction = " $direction" if $direction;
|
|
return "$field$direction";
|
|
}
|
|
|
|
# A hashref that describes all the special stuff that has to be done
|
|
# for various fields if they go into the ORDER BY clause.
|
|
sub _special_order {
|
|
my ($self) = @_;
|
|
return $self->{special_order} if $self->{special_order};
|
|
|
|
my %special_order = %{ SPECIAL_ORDER() };
|
|
my $select_fields = Bugzilla->fields({ type => FIELD_TYPE_SINGLE_SELECT });
|
|
foreach my $field (@$select_fields) {
|
|
next if $field->is_abnormal;
|
|
my $name = $field->name;
|
|
$special_order{$name} = {
|
|
order => ["map_$name.sortkey", "map_$name.value"],
|
|
join => {
|
|
table => $name,
|
|
from => "bugs.$name",
|
|
to => "value",
|
|
join => 'INNER',
|
|
}
|
|
};
|
|
}
|
|
$self->{special_order} = \%special_order;
|
|
return $self->{special_order};
|
|
}
|
|
|
|
sub _sql_order_by {
|
|
my ($self) = @_;
|
|
if (!$self->{sql_order_by}) {
|
|
my @order_by = map { $self->_translate_order_by_column($_) }
|
|
$self->_valid_order;
|
|
$self->{sql_order_by} = \@order_by;
|
|
}
|
|
return @{ $self->{sql_order_by} };
|
|
}
|
|
|
|
sub _translate_order_by_column {
|
|
my ($self, $order_by_item) = @_;
|
|
|
|
my ($field, $direction) = split_order_term($order_by_item);
|
|
|
|
$direction = '' if lc($direction) eq 'asc';
|
|
my $special_order = $self->_special_order->{$field}->{order};
|
|
# Standard fields have underscores in their SELECT alias instead
|
|
# of a period (because aliases can't have periods).
|
|
$field =~ s/\./_/g;
|
|
my @items = $special_order ? @$special_order : $field;
|
|
if (lc($direction) eq 'desc') {
|
|
@items = map { "$_ DESC" } @items;
|
|
}
|
|
return @items;
|
|
}
|
|
|
|
#############################
|
|
# Internal Accessors: LIMIT #
|
|
#############################
|
|
|
|
sub _sql_limit {
|
|
my ($self) = @_;
|
|
my $limit = $self->_params->{limit};
|
|
my $offset = $self->_params->{offset};
|
|
|
|
my $max_results = Bugzilla->params->{'max_search_results'};
|
|
if (!$self->{allow_unlimited} && (!$limit || $limit > $max_results)) {
|
|
$limit = $max_results;
|
|
}
|
|
|
|
if (defined($offset) && !$limit) {
|
|
$limit = INT_MAX;
|
|
}
|
|
if (defined $limit) {
|
|
detaint_natural($limit)
|
|
|| ThrowCodeError('param_must_be_numeric',
|
|
{ function => 'Bugzilla::Search::new',
|
|
param => 'limit' });
|
|
if (defined $offset) {
|
|
detaint_natural($offset)
|
|
|| ThrowCodeError('param_must_be_numeric',
|
|
{ function => 'Bugzilla::Search::new',
|
|
param => 'offset' });
|
|
}
|
|
return Bugzilla->dbh->sql_limit($limit, $offset);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
############################
|
|
# Internal Accessors: FROM #
|
|
############################
|
|
|
|
sub _column_join {
|
|
my ($self, $field) = @_;
|
|
# The _realname fields require the same join as the username fields.
|
|
$field =~ s/_realname$//;
|
|
my $column_joins = $self->_get_column_joins();
|
|
my $join_info = $column_joins->{$field};
|
|
if ($join_info) {
|
|
# Don't allow callers to modify the constant.
|
|
$join_info = dclone($join_info);
|
|
}
|
|
else {
|
|
if ($self->_multi_select_fields->{$field}) {
|
|
$join_info = { table => "bug_$field" };
|
|
}
|
|
}
|
|
if ($join_info and !$join_info->{as}) {
|
|
$join_info = dclone($join_info);
|
|
$join_info->{as} = "map_$field";
|
|
}
|
|
return $join_info ? $join_info : ();
|
|
}
|
|
|
|
# Sometimes we join the same table more than once. In this case, we
|
|
# want to AND all the various critiera that were used in both joins.
|
|
sub _combine_joins {
|
|
my ($self, $joins) = @_;
|
|
my @result;
|
|
while(my $join = shift @$joins) {
|
|
my $name = $join->{as};
|
|
my ($others_like_me, $the_rest) = part { $_->{as} eq $name ? 0 : 1 }
|
|
@$joins;
|
|
if ($others_like_me) {
|
|
my $from = $join->{from};
|
|
my $to = $join->{to};
|
|
# Sanity check to make sure that we have the same from and to
|
|
# for all the same-named joins.
|
|
if ($from) {
|
|
all { $_->{from} eq $from } @$others_like_me
|
|
or die "Not all same-named joins have identical 'from': "
|
|
. Dumper($join, $others_like_me);
|
|
}
|
|
if ($to) {
|
|
all { $_->{to} eq $to } @$others_like_me
|
|
or die "Not all same-named joins have identical 'to': "
|
|
. Dumper($join, $others_like_me);
|
|
}
|
|
|
|
# We don't need to call uniq here--translate_join will do that
|
|
# for us.
|
|
my @conditions = map { @{ $_->{extra} || [] } }
|
|
($join, @$others_like_me);
|
|
$join->{extra} = \@conditions;
|
|
$joins = $the_rest;
|
|
}
|
|
push(@result, $join);
|
|
}
|
|
|
|
return @result;
|
|
}
|
|
|
|
# Takes all the "then_to" items and just puts them as the next item in
|
|
# the array. Right now this only does one level of "then_to", but we
|
|
# could re-write this to handle then_to recursively if we need more levels.
|
|
sub _extract_then_to {
|
|
my ($self, $joins) = @_;
|
|
my @result;
|
|
foreach my $join (@$joins) {
|
|
push(@result, $join);
|
|
if (my $then_to = $join->{then_to}) {
|
|
push(@result, $then_to);
|
|
}
|
|
}
|
|
return @result;
|
|
}
|
|
|
|
# JOIN statements for the SELECT and ORDER BY columns. This should not be
|
|
# called until the moment it is needed, because _select_columns might be
|
|
# modified by the charts.
|
|
sub _select_order_joins {
|
|
my ($self) = @_;
|
|
my @joins;
|
|
foreach my $field ($self->_select_columns) {
|
|
my @column_join = $self->_column_join($field);
|
|
push(@joins, @column_join);
|
|
}
|
|
foreach my $field ($self->_valid_order_columns) {
|
|
my $join_info = $self->_special_order->{$field}->{join};
|
|
if ($join_info) {
|
|
# Don't let callers modify SPECIAL_ORDER.
|
|
$join_info = dclone($join_info);
|
|
if (!$join_info->{as}) {
|
|
$join_info->{as} = "map_$field";
|
|
}
|
|
push(@joins, $join_info);
|
|
}
|
|
}
|
|
return @joins;
|
|
}
|
|
|
|
# These are the joins that are *always* in the FROM clause.
|
|
sub _standard_joins {
|
|
my ($self) = @_;
|
|
my $user = $self->_user;
|
|
my @joins;
|
|
return () if $self->{_no_security_check};
|
|
|
|
my $security_join = {
|
|
table => 'bug_group_map',
|
|
as => 'security_map',
|
|
};
|
|
push(@joins, $security_join);
|
|
|
|
if ($user->id) {
|
|
# See also _standard_joins for the other half of the below statement
|
|
if (!Bugzilla->params->{'or_groups'}) {
|
|
$security_join->{extra} =
|
|
["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"];
|
|
}
|
|
|
|
my $security_cc_join = {
|
|
table => 'cc',
|
|
as => 'security_cc',
|
|
extra => ['security_cc.who = ' . $user->id],
|
|
};
|
|
push(@joins, $security_cc_join);
|
|
}
|
|
|
|
return @joins;
|
|
}
|
|
|
|
sub _sql_from {
|
|
my ($self, $joins_input) = @_;
|
|
my @joins = ($self->_standard_joins, $self->_select_order_joins,
|
|
@$joins_input);
|
|
@joins = $self->_extract_then_to(\@joins);
|
|
@joins = $self->_combine_joins(\@joins);
|
|
my @join_sql = map { $self->_translate_join($_) } @joins;
|
|
return "bugs\n" . join("\n", @join_sql);
|
|
}
|
|
|
|
# This takes a join data structure and turns it into actual JOIN SQL.
|
|
sub _translate_join {
|
|
my ($self, $join_info) = @_;
|
|
|
|
die "join with no table: " . Dumper($join_info) if !$join_info->{table};
|
|
die "join with no 'as': " . Dumper($join_info) if !$join_info->{as};
|
|
|
|
my $from_table = $join_info->{bugs_table} || "bugs";
|
|
my $from = $join_info->{from} || "bug_id";
|
|
if ($from =~ /^(\w+)\.(\w+)$/) {
|
|
($from_table, $from) = ($1, $2);
|
|
}
|
|
my $table = $join_info->{table};
|
|
my $name = $join_info->{as};
|
|
my $to = $join_info->{to} || "bug_id";
|
|
my $join = $join_info->{join} || 'LEFT';
|
|
my @extra = @{ $join_info->{extra} || [] };
|
|
$name =~ s/\./_/g;
|
|
|
|
# If a term contains ORs, we need to put parens around the condition.
|
|
# This is a pretty weak test, but it's actually OK to put parens
|
|
# around too many things.
|
|
@extra = map { $_ =~ /\bOR\b/i ? "($_)" : $_ } @extra;
|
|
my $extra_condition = join(' AND ', uniq @extra);
|
|
if ($extra_condition) {
|
|
$extra_condition = " AND $extra_condition";
|
|
}
|
|
|
|
my @join_sql = "$join JOIN $table AS $name"
|
|
. " ON $from_table.$from = $name.$to$extra_condition";
|
|
return @join_sql;
|
|
}
|
|
|
|
#############################
|
|
# Internal Accessors: WHERE #
|
|
#############################
|
|
|
|
# Note: There's also quite a bit of stuff that affects the WHERE clause
|
|
# in the "Internal Accessors: Boolean Charts" section.
|
|
|
|
# The terms that are always in the WHERE clause. These implement bug
|
|
# group security.
|
|
sub _standard_where {
|
|
my ($self) = @_;
|
|
return ('1=1') if $self->{_no_security_check};
|
|
# If replication lags badly between the shadow db and the main DB,
|
|
# it's possible for bugs to show up in searches before their group
|
|
# controls are properly set. To prevent this, when initially creating
|
|
# bugs we set their creation_ts to NULL, and don't give them a creation_ts
|
|
# until their group controls are set. So if a bug has a NULL creation_ts,
|
|
# it shouldn't show up in searches at all.
|
|
my @where = ('bugs.creation_ts IS NOT NULL');
|
|
|
|
my $user = $self->_user;
|
|
my $security_term = '';
|
|
# See also _standard_joins for the other half of the below statement
|
|
if (Bugzilla->params->{'or_groups'}) {
|
|
$security_term .= " (security_map.group_id IS NULL OR security_map.group_id IN (" . $user->groups_as_string . "))";
|
|
}
|
|
else {
|
|
$security_term = 'security_map.group_id IS NULL';
|
|
}
|
|
|
|
if ($user->id) {
|
|
my $userid = $user->id;
|
|
# This indentation makes the resulting SQL more readable.
|
|
$security_term .= <<END;
|
|
|
|
OR (bugs.reporter_accessible = 1 AND bugs.reporter = $userid)
|
|
OR (bugs.cclist_accessible = 1 AND security_cc.who IS NOT NULL)
|
|
OR bugs.assigned_to = $userid
|
|
END
|
|
if (Bugzilla->params->{'useqacontact'}) {
|
|
$security_term.= " OR bugs.qa_contact = $userid";
|
|
}
|
|
$security_term = "($security_term)";
|
|
}
|
|
|
|
push(@where, $security_term);
|
|
|
|
return @where;
|
|
}
|
|
|
|
sub _sql_where {
|
|
my ($self, $main_clause) = @_;
|
|
# The newline and this particular spacing makes the resulting
|
|
# SQL a bit more readable for debugging.
|
|
my $where = join("\n AND ", $self->_standard_where);
|
|
my $clause_sql = $main_clause->as_string;
|
|
$where .= "\n AND " . $clause_sql if $clause_sql;
|
|
return $where;
|
|
}
|
|
|
|
################################
|
|
# Internal Accessors: GROUP BY #
|
|
################################
|
|
|
|
# And these are the fields that we have to do GROUP BY for in DBs
|
|
# that are more strict about putting everything into GROUP BY.
|
|
sub _sql_group_by {
|
|
my ($self) = @_;
|
|
|
|
# Strict DBs require every element from the SELECT to be in the GROUP BY,
|
|
# unless that element is being used in an aggregate function.
|
|
my @extra_group_by;
|
|
foreach my $column ($self->_select_columns) {
|
|
next if $self->_skip_group_by->{$column};
|
|
my $sql = $self->COLUMNS->{$column}->{name} // $column;
|
|
push(@extra_group_by, $sql);
|
|
}
|
|
|
|
# And all items from ORDER BY must be in the GROUP BY. The above loop
|
|
# doesn't catch items that were put into the ORDER BY from SPECIAL_ORDER.
|
|
foreach my $column ($self->_valid_order_columns) {
|
|
my $special_order = $self->_special_order->{$column}->{order};
|
|
next if !$special_order;
|
|
push(@extra_group_by, @$special_order);
|
|
}
|
|
|
|
@extra_group_by = uniq @extra_group_by;
|
|
|
|
# bug_id is the only field we actually group by.
|
|
return ('bugs.bug_id', join(',', @extra_group_by));
|
|
}
|
|
|
|
# A helper for _sql_group_by.
|
|
sub _skip_group_by {
|
|
my ($self) = @_;
|
|
return $self->{skip_group_by} if $self->{skip_group_by};
|
|
my @skip_list = GROUP_BY_SKIP;
|
|
push(@skip_list, keys %{ $self->_multi_select_fields });
|
|
my %skip_hash = map { $_ => 1 } @skip_list;
|
|
$self->{skip_group_by} = \%skip_hash;
|
|
return $self->{skip_group_by};
|
|
}
|
|
|
|
##############################################
|
|
# Internal Accessors: Special Params Parsing #
|
|
##############################################
|
|
|
|
# Backwards compatibility for old field names.
|
|
sub _convert_old_params {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
|
|
# bugidtype has different values in modern Search.pm.
|
|
if (defined $params->{'bugidtype'}) {
|
|
my $value = $params->{'bugidtype'};
|
|
$params->{'bugidtype'} = $value eq 'exclude' ? 'nowords' : 'anyexact';
|
|
}
|
|
|
|
foreach my $old_name (keys %{ FIELD_MAP() }) {
|
|
if (defined $params->{$old_name}) {
|
|
my $new_name = FIELD_MAP->{$old_name};
|
|
$params->{$new_name} = delete $params->{$old_name};
|
|
}
|
|
}
|
|
}
|
|
|
|
# This parses all the standard search parameters except for the boolean
|
|
# charts.
|
|
sub _special_charts {
|
|
my ($self) = @_;
|
|
$self->_convert_old_params();
|
|
$self->_special_parse_bug_status();
|
|
$self->_special_parse_resolution();
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
$clause->add( $self->_parse_basic_fields() );
|
|
$clause->add( $self->_special_parse_email() );
|
|
$clause->add( $self->_special_parse_chfield() );
|
|
$clause->add( $self->_special_parse_deadline() );
|
|
return $clause;
|
|
}
|
|
|
|
sub _parse_basic_fields {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
my $chart_fields = $self->_chart_fields;
|
|
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
foreach my $field_name (keys %$chart_fields) {
|
|
# CGI params shouldn't have periods in them, so we only accept
|
|
# period-separated fields with underscores where the periods go.
|
|
my $param_name = $field_name;
|
|
$param_name =~ s/\./_/g;
|
|
my @values = $self->_param_array($param_name);
|
|
next if !@values;
|
|
my $default_op = $param_name eq 'content' ? 'matches' : 'anyexact';
|
|
my $operator = $params->{"${param_name}_type"} || $default_op;
|
|
# Fields that are displayed as multi-selects are passed as arrays,
|
|
# so that they can properly search values that contain commas.
|
|
# However, other fields are sent as strings, so that they are properly
|
|
# split on commas if required.
|
|
my $field = $chart_fields->{$field_name};
|
|
my $pass_value;
|
|
if ($field->is_select or $field->name eq 'version'
|
|
or $field->name eq 'target_milestone')
|
|
{
|
|
$pass_value = \@values;
|
|
}
|
|
else {
|
|
$pass_value = join(',', @values);
|
|
}
|
|
$clause->add($field_name, $operator, $pass_value);
|
|
}
|
|
return @{$clause->children} ? $clause : undef;
|
|
}
|
|
|
|
sub _special_parse_bug_status {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
return if !defined $params->{'bug_status'};
|
|
# We want to allow the bug_status_type parameter to work normally,
|
|
# meaning that this special code should only be activated if we are
|
|
# doing the normal "anyexact" search on bug_status.
|
|
return if (defined $params->{'bug_status_type'}
|
|
and $params->{'bug_status_type'} ne 'anyexact');
|
|
|
|
my @bug_status = $self->_param_array('bug_status');
|
|
# Also include inactive bug statuses, as you can query them.
|
|
my $legal_statuses = $self->_chart_fields->{'bug_status'}->legal_values;
|
|
|
|
# If the status contains __open__ or __closed__, translate those
|
|
# into their equivalent lists of open and closed statuses.
|
|
if (grep { $_ eq '__open__' } @bug_status) {
|
|
my @open = grep { $_->is_open } @$legal_statuses;
|
|
@open = map { $_->name } @open;
|
|
push(@bug_status, @open);
|
|
}
|
|
if (grep { $_ eq '__closed__' } @bug_status) {
|
|
my @closed = grep { not $_->is_open } @$legal_statuses;
|
|
@closed = map { $_->name } @closed;
|
|
push(@bug_status, @closed);
|
|
}
|
|
|
|
@bug_status = uniq @bug_status;
|
|
my $all = grep { $_ eq "__all__" } @bug_status;
|
|
# This will also handle removing __open__ and __closed__ for us
|
|
# (__all__ too, which is why we check for it above, first).
|
|
@bug_status = _valid_values(\@bug_status, $legal_statuses);
|
|
|
|
# If the user has selected every status, change to selecting none.
|
|
# This is functionally equivalent, but quite a lot faster.
|
|
if ($all or scalar(@bug_status) == scalar(@$legal_statuses)) {
|
|
delete $params->{'bug_status'};
|
|
}
|
|
else {
|
|
$params->{'bug_status'} = \@bug_status;
|
|
}
|
|
}
|
|
|
|
sub _special_parse_chfield {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
|
|
my $date_from = trim(lc($params->{'chfieldfrom'} || ''));
|
|
my $date_to = trim(lc($params->{'chfieldto'} || ''));
|
|
$date_from = '' if $date_from eq 'now';
|
|
$date_to = '' if $date_to eq 'now';
|
|
my @fields = $self->_param_array('chfield');
|
|
my $value_to = $params->{'chfieldvalue'};
|
|
$value_to = '' if !defined $value_to;
|
|
|
|
@fields = map { $_ eq '[Bug creation]' ? 'creation_ts' : $_ } @fields;
|
|
|
|
return undef unless ($date_from ne '' || $date_to ne '' || $value_to ne '');
|
|
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
|
|
# It is always safe and useful to push delta_ts into the charts
|
|
# if there is a "from" date specified. It doesn't conflict with
|
|
# searching [Bug creation], because a bug's delta_ts is set to
|
|
# its creation_ts when it is created. So this just gives the
|
|
# database an additional index to possibly choose, on a table that
|
|
# is smaller than bugs_activity.
|
|
if ($date_from ne '') {
|
|
$clause->add('delta_ts', 'greaterthaneq', $date_from);
|
|
}
|
|
# It's not normally safe to do it for "to" dates, though--"chfieldto" means
|
|
# "a field that changed before this date", and delta_ts could be either
|
|
# later or earlier than that, if we're searching for the time that a field
|
|
# changed. However, chfieldto all by itself, without any chfieldvalue or
|
|
# chfield, means "just search delta_ts", and so we still want that to
|
|
# work.
|
|
if ($date_to ne '' and !@fields and $value_to eq '') {
|
|
$clause->add('delta_ts', 'lessthaneq', $date_to);
|
|
}
|
|
|
|
# chfieldto is supposed to be a relative date or a date of the form
|
|
# YYYY-MM-DD, i.e. without the time appended to it. We append the
|
|
# time ourselves so that the end date is correctly taken into account.
|
|
$date_to .= ' 23:59:59' if $date_to =~ /^\d{4}-\d{1,2}-\d{1,2}$/;
|
|
|
|
my $join_clause = new Bugzilla::Search::Clause('OR');
|
|
|
|
foreach my $field (@fields) {
|
|
my $sub_clause = new Bugzilla::Search::ClauseGroup();
|
|
$sub_clause->add(condition($field, 'changedto', $value_to)) if $value_to ne '';
|
|
$sub_clause->add(condition($field, 'changedafter', $date_from)) if $date_from ne '';
|
|
$sub_clause->add(condition($field, 'changedbefore', $date_to)) if $date_to ne '';
|
|
$join_clause->add($sub_clause);
|
|
}
|
|
$clause->add($join_clause);
|
|
|
|
return @{$clause->children} ? $clause : undef;
|
|
}
|
|
|
|
sub _special_parse_deadline {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
if (my $from = $params->{'deadlinefrom'}) {
|
|
$clause->add('deadline', 'greaterthaneq', $from);
|
|
}
|
|
if (my $to = $params->{'deadlineto'}) {
|
|
$clause->add('deadline', 'lessthaneq', $to);
|
|
}
|
|
|
|
return @{$clause->children} ? $clause : undef;
|
|
}
|
|
|
|
sub _special_parse_email {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
|
|
my @email_params = grep { $_ =~ /^email\d+$/ } keys %$params;
|
|
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
foreach my $param (@email_params) {
|
|
$param =~ /(\d+)$/;
|
|
my $id = $1;
|
|
my $email = trim($params->{"email$id"});
|
|
next if !$email;
|
|
my $type = $params->{"emailtype$id"} || 'anyexact';
|
|
# for backward compatibility
|
|
$type = "equals" if $type eq "exact";
|
|
|
|
my $or_clause = new Bugzilla::Search::Clause('OR');
|
|
foreach my $field (qw(assigned_to reporter cc qa_contact)) {
|
|
if ($params->{"email$field$id"}) {
|
|
$or_clause->add($field, $type, $email);
|
|
}
|
|
}
|
|
if ($params->{"emaillongdesc$id"}) {
|
|
$or_clause->add("commenter", $type, $email);
|
|
}
|
|
|
|
$clause->add($or_clause);
|
|
}
|
|
|
|
return @{$clause->children} ? $clause : undef;
|
|
}
|
|
|
|
sub _special_parse_resolution {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
return if !defined $params->{'resolution'};
|
|
|
|
my @resolution = $self->_param_array('resolution');
|
|
my $legal_resolutions = $self->_chart_fields->{resolution}->legal_values;
|
|
@resolution = _valid_values(\@resolution, $legal_resolutions, '---');
|
|
if (scalar(@resolution) == scalar(@$legal_resolutions)) {
|
|
delete $params->{'resolution'};
|
|
}
|
|
}
|
|
|
|
sub _valid_values {
|
|
my ($input, $valid, $extra_value) = @_;
|
|
my @result;
|
|
foreach my $item (@$input) {
|
|
$item = trim($item);
|
|
if (defined $extra_value and $item eq $extra_value) {
|
|
push(@result, $item);
|
|
}
|
|
elsif (grep { $_->name eq $item } @$valid) {
|
|
push(@result, $item);
|
|
}
|
|
}
|
|
return @result;
|
|
}
|
|
|
|
######################################
|
|
# Internal Accessors: Boolean Charts #
|
|
######################################
|
|
|
|
sub _charts_to_conditions {
|
|
my ($self) = @_;
|
|
|
|
my $clause = $self->_charts;
|
|
my @joins;
|
|
$clause->walk_conditions(sub {
|
|
my ($clause, $condition) = @_;
|
|
return if !$condition->translated;
|
|
push(@joins, @{ $condition->translated->{joins} });
|
|
});
|
|
return (\@joins, $clause);
|
|
}
|
|
|
|
sub _charts {
|
|
my ($self) = @_;
|
|
|
|
my $clause = $self->_params_to_data_structure();
|
|
my $chart_id = 0;
|
|
$clause->walk_conditions(sub { $self->_handle_chart($chart_id++, @_) });
|
|
return $clause;
|
|
}
|
|
|
|
sub _params_to_data_structure {
|
|
my ($self) = @_;
|
|
|
|
# First we get the "special" charts, representing all the normal
|
|
# fields on the search page. This may modify _params, so it needs to
|
|
# happen first.
|
|
my $clause = $self->_special_charts;
|
|
|
|
# Then we process the old Boolean Charts input format.
|
|
$clause->add( $self->_boolean_charts );
|
|
|
|
# And then process the modern "custom search" format.
|
|
$clause->add( $self->_custom_search );
|
|
|
|
return $clause;
|
|
}
|
|
|
|
sub _boolean_charts {
|
|
my ($self) = @_;
|
|
|
|
my $params = $self->_params;
|
|
my @param_list = keys %$params;
|
|
|
|
my @all_field_params = grep { /^field-?\d+/ } @param_list;
|
|
my @chart_ids = map { /^field(-?\d+)/; $1 } @all_field_params;
|
|
@chart_ids = sort { $a <=> $b } uniq @chart_ids;
|
|
|
|
my $clause = new Bugzilla::Search::Clause();
|
|
foreach my $chart_id (@chart_ids) {
|
|
my @all_and = grep { /^field$chart_id-\d+/ } @param_list;
|
|
my @and_ids = map { /^field$chart_id-(\d+)/; $1 } @all_and;
|
|
@and_ids = sort { $a <=> $b } uniq @and_ids;
|
|
|
|
my $and_clause = new Bugzilla::Search::Clause();
|
|
foreach my $and_id (@and_ids) {
|
|
my @all_or = grep { /^field$chart_id-$and_id-\d+/ } @param_list;
|
|
my @or_ids = map { /^field$chart_id-$and_id-(\d+)/; $1 } @all_or;
|
|
@or_ids = sort { $a <=> $b } uniq @or_ids;
|
|
|
|
my $or_clause = new Bugzilla::Search::Clause('OR');
|
|
foreach my $or_id (@or_ids) {
|
|
my $identifier = "$chart_id-$and_id-$or_id";
|
|
my $field = $params->{"field$identifier"};
|
|
my $operator = $params->{"type$identifier"};
|
|
my $value = $params->{"value$identifier"};
|
|
# no-value operators ignore the value, however a value needs to be set
|
|
$value = ' ' if $operator && grep { $_ eq $operator } NO_VALUE_OPERATORS;
|
|
$or_clause->add($field, $operator, $value);
|
|
}
|
|
$and_clause->add($or_clause);
|
|
$and_clause->negate(1) if $params->{"negate$chart_id"};
|
|
}
|
|
$clause->add($and_clause);
|
|
}
|
|
|
|
return @{$clause->children} ? $clause : undef;
|
|
}
|
|
|
|
sub _custom_search {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
|
|
my @field_ids = $self->_field_ids;
|
|
return unless scalar @field_ids;
|
|
|
|
my $joiner = $params->{j_top} || '';
|
|
my $current_clause = $joiner eq 'AND_G'
|
|
? new Bugzilla::Search::ClauseGroup()
|
|
: new Bugzilla::Search::Clause($joiner);
|
|
|
|
my @clause_stack;
|
|
foreach my $id (@field_ids) {
|
|
my $field = $params->{"f$id"};
|
|
if ($field eq 'OP') {
|
|
my $joiner = $params->{"j$id"} || '';
|
|
my $new_clause = $joiner eq 'AND_G'
|
|
? new Bugzilla::Search::ClauseGroup()
|
|
: new Bugzilla::Search::Clause($joiner);
|
|
$new_clause->negate($params->{"n$id"});
|
|
$current_clause->add($new_clause);
|
|
push(@clause_stack, $current_clause);
|
|
$current_clause = $new_clause;
|
|
next;
|
|
}
|
|
if ($field eq 'CP') {
|
|
$current_clause = pop @clause_stack;
|
|
ThrowCodeError('search_cp_without_op', { id => $id })
|
|
if !$current_clause;
|
|
next;
|
|
}
|
|
|
|
my $operator = $params->{"o$id"};
|
|
my $value = $params->{"v$id"};
|
|
# no-value operators ignore the value, however a value needs to be set
|
|
$value = ' ' if $operator && grep { $_ eq $operator } NO_VALUE_OPERATORS;
|
|
my $condition = condition($field, $operator, $value);
|
|
$condition->negate($params->{"n$id"});
|
|
$current_clause->add($condition);
|
|
}
|
|
|
|
# We allow people to specify more OPs than CPs, so at the end of the
|
|
# loop our top clause may be still in the stack instead of being
|
|
# $current_clause.
|
|
return $clause_stack[0] || $current_clause;
|
|
}
|
|
|
|
sub _field_ids {
|
|
my ($self) = @_;
|
|
my $params = $self->_params;
|
|
my @param_list = keys %$params;
|
|
|
|
my @field_params = grep { /^f\d+$/ } @param_list;
|
|
my @field_ids = map { /(\d+)/; $1 } @field_params;
|
|
@field_ids = sort { $a <=> $b } @field_ids;
|
|
return @field_ids;
|
|
}
|
|
|
|
sub _handle_chart {
|
|
my ($self, $chart_id, $clause, $condition) = @_;
|
|
my $dbh = Bugzilla->dbh;
|
|
my $params = $self->_params;
|
|
my ($field, $operator, $value) = $condition->fov;
|
|
return if (!defined $field or !defined $operator or !defined $value);
|
|
$field = FIELD_MAP->{$field} || $field;
|
|
|
|
my ($string_value, $orig_value);
|
|
state $is_mysql = $dbh->isa('Bugzilla::DB::Mysql') ? 1 : 0;
|
|
|
|
if (ref $value eq 'ARRAY') {
|
|
# Trim input and ignore blank values.
|
|
@$value = map { trim($_) } @$value;
|
|
@$value = grep { defined $_ and $_ ne '' } @$value;
|
|
return if !@$value;
|
|
$orig_value = join(',', @$value);
|
|
if ($field eq 'longdesc' && $is_mysql) {
|
|
@$value = map { _convert_unicode_characters($_) } @$value;
|
|
}
|
|
$string_value = join(',', @$value);
|
|
}
|
|
else {
|
|
return if $value eq '';
|
|
$orig_value = $value;
|
|
if ($field eq 'longdesc' && $is_mysql) {
|
|
$value = _convert_unicode_characters($value);
|
|
}
|
|
$string_value = $value;
|
|
}
|
|
|
|
$self->_chart_fields->{$field}
|
|
or ThrowCodeError("invalid_field_name", { field => $field });
|
|
trick_taint($field);
|
|
|
|
# This is the field as you'd reference it in a SQL statement.
|
|
my $full_field = $field =~ /\./ ? $field : "bugs.$field";
|
|
|
|
# "value" and "quoted" are for search functions that always operate
|
|
# on a scalar string and never care if they were passed multiple
|
|
# parameters. If the user does pass multiple parameters, they will
|
|
# become a space-separated string for those search functions.
|
|
#
|
|
# all_values is for search functions that do operate
|
|
# on multiple values, like anyexact.
|
|
|
|
my %search_args = (
|
|
chart_id => $chart_id,
|
|
sequence => $chart_id,
|
|
field => $field,
|
|
full_field => $full_field,
|
|
operator => $operator,
|
|
value => $string_value,
|
|
all_values => $value,
|
|
joins => [],
|
|
bugs_table => 'bugs',
|
|
table_suffix => '',
|
|
condition => $condition,
|
|
);
|
|
$clause->update_search_args(\%search_args);
|
|
|
|
$search_args{quoted} = $self->_quote_unless_numeric(\%search_args);
|
|
# This should add a "term" selement to %search_args.
|
|
$self->do_search_function(\%search_args);
|
|
|
|
# If term is left empty, then this means the criteria
|
|
# has no effect and can be ignored.
|
|
return unless $search_args{term};
|
|
|
|
# All the things here that don't get pulled out of
|
|
# %search_args are their original values before
|
|
# do_search_function modified them.
|
|
$self->search_description({
|
|
field => $field, type => $operator,
|
|
value => $orig_value, term => $search_args{term},
|
|
});
|
|
|
|
foreach my $join (@{ $search_args{joins} }) {
|
|
$join->{bugs_table} = $search_args{bugs_table};
|
|
$join->{table_suffix} = $search_args{table_suffix};
|
|
}
|
|
|
|
$condition->translated(\%search_args);
|
|
}
|
|
|
|
# XXX - This is a hack for MySQL which doesn't understand Unicode characters
|
|
# above U+FFFF, see Bugzilla::Comment::_check_thetext(). This hack can go away
|
|
# once we require MySQL 5.5.3 and use utf8mb4.
|
|
sub _convert_unicode_characters {
|
|
my $string = shift;
|
|
|
|
# Perl 5.13.8 and older complain about non-characters.
|
|
no warnings 'utf8';
|
|
$string =~ s/([\x{10000}-\x{10FFFF}])/"\x{FDD0}[" . uc(sprintf('U+%04x', ord($1))) . "]\x{FDD1}"/eg;
|
|
return $string;
|
|
}
|
|
|
|
##################################
|
|
# do_search_function And Helpers #
|
|
##################################
|
|
|
|
# This takes information about the current boolean chart and translates
|
|
# it into SQL, using the constants at the top of this file.
|
|
sub do_search_function {
|
|
my ($self, $args) = @_;
|
|
my ($field, $operator) = @$args{qw(field operator)};
|
|
|
|
if (my $parse_func = SPECIAL_PARSING->{$field}) {
|
|
$self->$parse_func($args);
|
|
# Some parsing functions set $term, though most do not.
|
|
# For the ones that set $term, we don't need to do any further
|
|
# parsing.
|
|
return if $args->{term};
|
|
}
|
|
|
|
my $operator_field_override = $self->_get_operator_field_override();
|
|
my $override = $operator_field_override->{$field};
|
|
# Attachment fields get special handling, if they don't have a specific
|
|
# individual override.
|
|
if (!$override and $field =~ /^attachments\./) {
|
|
$override = $operator_field_override->{attachments};
|
|
}
|
|
# If there's still no override, check for an override on the field's type.
|
|
if (!$override) {
|
|
my $field_obj = $self->_chart_fields->{$field};
|
|
$override = $operator_field_override->{$field_obj->type};
|
|
}
|
|
|
|
if ($override) {
|
|
my $search_func = $self->_pick_override_function($override, $operator);
|
|
$self->$search_func($args) if $search_func;
|
|
}
|
|
|
|
# Some search functions set $term, and some don't. For the ones that
|
|
# don't (or for fields that don't have overrides) we now call the
|
|
# direct operator function from OPERATORS.
|
|
if (!defined $args->{term}) {
|
|
$self->_do_operator_function($args);
|
|
}
|
|
|
|
if (!defined $args->{term}) {
|
|
# This field and this type don't work together. Generally,
|
|
# this should never be reached, because it should be handled
|
|
# explicitly by OPERATOR_FIELD_OVERRIDE.
|
|
ThrowUserError("search_field_operator_invalid",
|
|
{ field => $field, operator => $operator });
|
|
}
|
|
}
|
|
|
|
# A helper for various search functions that need to run operator
|
|
# functions directly.
|
|
sub _do_operator_function {
|
|
my ($self, $func_args) = @_;
|
|
my $operator = $func_args->{operator};
|
|
my $operator_func = OPERATORS->{$operator}
|
|
|| ThrowCodeError("search_field_operator_unsupported",
|
|
{ operator => $operator });
|
|
$self->$operator_func($func_args);
|
|
}
|
|
|
|
sub _reverse_operator {
|
|
my ($self, $operator) = @_;
|
|
my $reverse = OPERATOR_REVERSE->{$operator};
|
|
return $reverse if $reverse;
|
|
if ($operator =~ s/^not//) {
|
|
return $operator;
|
|
}
|
|
return "not$operator";
|
|
}
|
|
|
|
sub _pick_override_function {
|
|
my ($self, $override, $operator) = @_;
|
|
my $search_func = $override->{$operator};
|
|
|
|
if (!$search_func) {
|
|
# If we don't find an override for one specific operator,
|
|
# then there are some special override types:
|
|
# _non_changed: For any operator that doesn't have the word
|
|
# "changed" in it
|
|
# _default: Overrides all operators that aren't explicitly specified.
|
|
if ($override->{_non_changed} and $operator !~ /changed/) {
|
|
$search_func = $override->{_non_changed};
|
|
}
|
|
elsif ($override->{_default}) {
|
|
$search_func = $override->{_default};
|
|
}
|
|
}
|
|
|
|
return $search_func;
|
|
}
|
|
|
|
sub _get_operator_field_override {
|
|
my $self = shift;
|
|
my $cache = Bugzilla->request_cache;
|
|
|
|
return $cache->{operator_field_override}
|
|
if defined $cache->{operator_field_override};
|
|
|
|
my %operator_field_override = %{ OPERATOR_FIELD_OVERRIDE() };
|
|
Bugzilla::Hook::process('search_operator_field_override',
|
|
{ search => $self,
|
|
operators => \%operator_field_override });
|
|
|
|
$cache->{operator_field_override} = \%operator_field_override;
|
|
return $cache->{operator_field_override};
|
|
}
|
|
|
|
sub _get_column_joins {
|
|
my $self = shift;
|
|
my $cache = Bugzilla->request_cache;
|
|
|
|
return $cache->{column_joins} if defined $cache->{column_joins};
|
|
|
|
my %column_joins = %{ $self->COLUMN_JOINS() };
|
|
Bugzilla::Hook::process('buglist_column_joins',
|
|
{ column_joins => \%column_joins });
|
|
|
|
$cache->{column_joins} = \%column_joins;
|
|
return $cache->{column_joins};
|
|
}
|
|
|
|
###########################
|
|
# Search Function Helpers #
|
|
###########################
|
|
|
|
# When we're doing a numeric search against a numeric column, we want to
|
|
# just put a number into the SQL instead of a string. On most DBs, this
|
|
# is just a performance optimization, but on SQLite it actually changes
|
|
# the behavior of some searches.
|
|
sub _quote_unless_numeric {
|
|
my ($self, $args, $value) = @_;
|
|
if (!defined $value) {
|
|
$value = $args->{value};
|
|
}
|
|
my ($field, $operator) = @$args{qw(field operator)};
|
|
|
|
my $numeric_operator = !grep { $_ eq $operator } NON_NUMERIC_OPERATORS;
|
|
my $numeric_field = $self->_chart_fields->{$field}->is_numeric;
|
|
my $numeric_value = ($value =~ NUMBER_REGEX) ? 1 : 0;
|
|
my $is_numeric = $numeric_operator && $numeric_field && $numeric_value;
|
|
|
|
# These operators are really numeric operators with numeric fields.
|
|
$numeric_operator = grep { $_ eq $operator } keys %{ SIMPLE_OPERATORS() };
|
|
|
|
if ($is_numeric) {
|
|
my $quoted = $value;
|
|
trick_taint($quoted);
|
|
return $quoted;
|
|
}
|
|
elsif ($numeric_field && !$numeric_value && $numeric_operator) {
|
|
ThrowUserError('number_not_numeric', { field => $field, num => $value });
|
|
}
|
|
return Bugzilla->dbh->quote($value);
|
|
}
|
|
|
|
sub build_subselect {
|
|
my ($outer, $inner, $table, $cond, $negate) = @_;
|
|
if ($table =~ /\battach_data\b/) {
|
|
# It takes a long time to scan the whole attach_data table
|
|
# unconditionally, so we return the subselect and let the DB optimizer
|
|
# restrict the search based on other search criteria.
|
|
my $not = $negate ? "NOT" : "";
|
|
return "$outer $not IN (SELECT DISTINCT $inner FROM $table WHERE $cond)";
|
|
}
|
|
# Execute subselects immediately to avoid dependent subqueries, which are
|
|
# large performance hits on MySql
|
|
my $q = "SELECT DISTINCT $inner FROM $table WHERE $cond";
|
|
my $dbh = Bugzilla->dbh;
|
|
my $list = $dbh->selectcol_arrayref($q);
|
|
return $negate ? "1=1" : "1=2" unless @$list;
|
|
return $dbh->sql_in($outer, $list, $negate);
|
|
}
|
|
|
|
# Used by anyexact to get the list of input values. This allows us to
|
|
# support values with commas inside of them in the standard charts, and
|
|
# still accept string values for the boolean charts (and split them on
|
|
# commas).
|
|
sub _all_values {
|
|
my ($self, $args, $split_on) = @_;
|
|
$split_on ||= qr/[\s,]+/;
|
|
my $dbh = Bugzilla->dbh;
|
|
my $all_values = $args->{all_values};
|
|
|
|
my @array;
|
|
if (ref $all_values eq 'ARRAY') {
|
|
@array = @$all_values;
|
|
}
|
|
else {
|
|
@array = split($split_on, $all_values);
|
|
@array = map { trim($_) } @array;
|
|
@array = grep { defined $_ and $_ ne '' } @array;
|
|
}
|
|
|
|
if ($args->{field} eq 'resolution') {
|
|
@array = map { $_ eq '---' ? '' : $_ } @array;
|
|
}
|
|
|
|
return @array;
|
|
}
|
|
|
|
# Support for "any/all/nowordssubstr" comparison type ("words as substrings")
|
|
sub _substring_terms {
|
|
my ($self, $args) = @_;
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
# We don't have to (or want to) use _all_values, because we'd just
|
|
# split each term on spaces and commas anyway.
|
|
my @words = split(/[\s,]+/, $args->{value});
|
|
@words = grep { defined $_ and $_ ne '' } @words;
|
|
my @terms = map { $dbh->sql_ilike($_, $args->{full_field}) } @words;
|
|
return @terms;
|
|
}
|
|
|
|
sub _word_terms {
|
|
my ($self, $args) = @_;
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my @values = split(/[\s,]+/, $args->{value});
|
|
@values = grep { defined $_ and $_ ne '' } @values;
|
|
my @substring_terms = $self->_substring_terms($args);
|
|
|
|
my @terms;
|
|
my $start = $dbh->WORD_START;
|
|
my $end = $dbh->WORD_END;
|
|
foreach my $word (@values) {
|
|
my $regex = $start . quotemeta($word) . $end;
|
|
my $quoted = $dbh->quote($regex);
|
|
# We don't have to check the regexp, because we escaped it, so we're
|
|
# sure it's valid.
|
|
my $regex_term = $dbh->sql_regexp($args->{full_field}, $quoted,
|
|
'no check');
|
|
# Regular expressions are slow--substring searches are faster.
|
|
# If we're searching for a word, we're also certain that the
|
|
# substring will appear in the value. So we limit first by
|
|
# substring and then by a regex that will match just words.
|
|
my $substring_term = shift @substring_terms;
|
|
push(@terms, "$substring_term AND $regex_term");
|
|
}
|
|
|
|
return @terms;
|
|
}
|
|
|
|
#####################################
|
|
# "Special Parsing" Functions: Date #
|
|
#####################################
|
|
|
|
sub _timestamp_translate {
|
|
my ($self, $ignore_time, $args) = @_;
|
|
my $value = $args->{value};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
return if $value !~ /^(?:[\+\-]?\d+[hdwmy]s?|now)$/i;
|
|
|
|
$value = SqlifyDate($value);
|
|
# By default, the time is appended to the date, which we don't always want.
|
|
if ($ignore_time) {
|
|
($value) = split(/\s/, $value);
|
|
}
|
|
$args->{value} = $value;
|
|
$args->{quoted} = $dbh->quote($value);
|
|
}
|
|
|
|
sub _datetime_translate {
|
|
return shift->_timestamp_translate(0, @_);
|
|
}
|
|
|
|
sub _last_visit_datetime {
|
|
my ($self, $args) = @_;
|
|
my $value = $args->{value};
|
|
|
|
$self->_datetime_translate($args);
|
|
if ($value eq $args->{value}) {
|
|
# Failed to translate a datetime. let's try the pronoun expando.
|
|
if ($value eq '%last_changed%') {
|
|
$self->_add_extra_column('changeddate');
|
|
$args->{value} = $args->{quoted} = 'bugs.delta_ts';
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
sub _date_translate {
|
|
return shift->_timestamp_translate(1, @_);
|
|
}
|
|
|
|
sub SqlifyDate {
|
|
my ($str) = @_;
|
|
my $fmt = "%Y-%m-%d %H:%M:%S";
|
|
$str = "" if (!defined $str || lc($str) eq 'now');
|
|
if ($str eq "") {
|
|
my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime(time());
|
|
return sprintf("%4d-%02d-%02d 00:00:00", $year+1900, $month+1, $mday);
|
|
}
|
|
|
|
if ($str =~ /^(-|\+)?(\d+)([hdwmy])(s?)$/i) { # relative date
|
|
my ($sign, $amount, $unit, $startof, $date) = ($1, $2, lc $3, lc $4, time);
|
|
my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date);
|
|
if ($sign && $sign eq '+') { $amount = -$amount; }
|
|
$startof = 1 if $amount == 0;
|
|
if ($unit eq 'w') { # convert weeks to days
|
|
$amount = 7*$amount;
|
|
$amount += $wday if $startof;
|
|
$unit = 'd';
|
|
}
|
|
if ($unit eq 'd') {
|
|
if ($startof) {
|
|
$fmt = "%Y-%m-%d 00:00:00";
|
|
$date -= $sec + 60*$min + 3600*$hour;
|
|
}
|
|
$date -= 24*3600*$amount;
|
|
return time2str($fmt, $date);
|
|
}
|
|
elsif ($unit eq 'y') {
|
|
if ($startof) {
|
|
return sprintf("%4d-01-01 00:00:00", $year+1900-$amount);
|
|
}
|
|
else {
|
|
return sprintf("%4d-%02d-%02d %02d:%02d:%02d",
|
|
$year+1900-$amount, $month+1, $mday, $hour, $min, $sec);
|
|
}
|
|
}
|
|
elsif ($unit eq 'm') {
|
|
$month -= $amount;
|
|
$year += floor($month/12);
|
|
$month %= 12;
|
|
if ($startof) {
|
|
return sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1);
|
|
}
|
|
else {
|
|
return sprintf("%4d-%02d-%02d %02d:%02d:%02d",
|
|
$year+1900, $month+1, $mday, $hour, $min, $sec);
|
|
}
|
|
}
|
|
elsif ($unit eq 'h') {
|
|
# Special case for 'beginning of an hour'
|
|
if ($startof) {
|
|
$fmt = "%Y-%m-%d %H:00:00";
|
|
}
|
|
$date -= 3600*$amount;
|
|
return time2str($fmt, $date);
|
|
}
|
|
return undef; # should not happen due to regexp at top
|
|
}
|
|
my $date = str2time($str);
|
|
if (!defined($date)) {
|
|
ThrowUserError("illegal_date", { date => $str });
|
|
}
|
|
return time2str($fmt, $date);
|
|
}
|
|
|
|
######################################
|
|
# "Special Parsing" Functions: Users #
|
|
######################################
|
|
|
|
sub pronoun {
|
|
my ($noun, $user) = (@_);
|
|
if ($noun eq "%user%") {
|
|
if ($user->id) {
|
|
return $user->id;
|
|
} else {
|
|
ThrowUserError('login_required_for_pronoun');
|
|
}
|
|
}
|
|
if ($noun eq "%reporter%") {
|
|
return "bugs.reporter";
|
|
}
|
|
if ($noun eq "%assignee%") {
|
|
return "bugs.assigned_to";
|
|
}
|
|
if ($noun eq "%qacontact%") {
|
|
return "COALESCE(bugs.qa_contact,0)";
|
|
}
|
|
|
|
ThrowUserError('illegal_pronoun', { pronoun => $noun });
|
|
}
|
|
|
|
sub _contact_pronoun {
|
|
my ($self, $args) = @_;
|
|
my $value = $args->{value};
|
|
my $user = $self->_user;
|
|
|
|
if ($value =~ /^\%group\.[^%]+%$/) {
|
|
$self->_contact_exact_group($args);
|
|
}
|
|
elsif ($value =~ /^(%\w+%)$/) {
|
|
$args->{value} = pronoun($1, $user);
|
|
$args->{quoted} = $args->{value};
|
|
$args->{value_is_id} = 1;
|
|
}
|
|
}
|
|
|
|
sub _contact_exact_group {
|
|
my ($self, $args) = @_;
|
|
my ($value, $operator, $field, $chart_id, $joins, $sequence) =
|
|
@$args{qw(value operator field chart_id joins sequence)};
|
|
my $dbh = Bugzilla->dbh;
|
|
my $user = $self->_user;
|
|
|
|
# We already know $value will match this regexp, else we wouldn't be here.
|
|
$value =~ /\%group\.([^%]+)%/;
|
|
my $group_name = $1;
|
|
my $group = Bugzilla::Group->check({ name => $group_name, _error => 'invalid_group_name' });
|
|
# Pass $group_name instead of $group->name to the error message
|
|
# to not leak the existence of the group.
|
|
$user->in_group($group)
|
|
|| ThrowUserError('invalid_group_name', { name => $group_name });
|
|
# Now that we know the user belongs to this group, it's safe
|
|
# to disclose more information.
|
|
$group->check_members_are_visible();
|
|
|
|
my $group_ids = Bugzilla::Group->flatten_group_membership($group->id);
|
|
|
|
if ($field eq 'cc' && $chart_id eq '') {
|
|
# This is for the email1, email2, email3 fields from query.cgi.
|
|
$chart_id = "CC$$sequence";
|
|
$args->{sequence}++;
|
|
}
|
|
|
|
my $from = $field;
|
|
# These fields need an additional table.
|
|
if ($field =~ /^(commenter|cc)$/) {
|
|
my $join_table = $field;
|
|
$join_table = 'longdescs' if $field eq 'commenter';
|
|
my $join_table_alias = "${field}_$chart_id";
|
|
push(@$joins, { table => $join_table, as => $join_table_alias });
|
|
$from = "$join_table_alias.who";
|
|
}
|
|
|
|
my $table = "user_group_map_$chart_id";
|
|
my $join = {
|
|
table => 'user_group_map',
|
|
as => $table,
|
|
from => $from,
|
|
to => 'user_id',
|
|
extra => [$dbh->sql_in("$table.group_id", $group_ids),
|
|
"$table.isbless = 0"],
|
|
};
|
|
push(@$joins, $join);
|
|
if ($operator =~ /^not/) {
|
|
$args->{term} = "$table.group_id IS NULL";
|
|
}
|
|
else {
|
|
$args->{term} = "$table.group_id IS NOT NULL";
|
|
}
|
|
}
|
|
|
|
sub _get_user_id {
|
|
my ($self, $value) = @_;
|
|
|
|
if ($value =~ /^%\w+%$/) {
|
|
return pronoun($value, $self->_user);
|
|
}
|
|
return login_to_id($value, THROW_ERROR);
|
|
}
|
|
|
|
#####################################################################
|
|
# Search Functions
|
|
#####################################################################
|
|
|
|
sub _invalid_combination {
|
|
my ($self, $args) = @_;
|
|
my ($field, $operator) = @$args{qw(field operator)};
|
|
ThrowUserError('search_field_operator_invalid',
|
|
{ field => $field, operator => $operator });
|
|
}
|
|
|
|
# For all the "user" fields--assigned_to, reporter, qa_contact,
|
|
# cc, commenter, requestee, etc.
|
|
sub _user_nonchanged {
|
|
my ($self, $args) = @_;
|
|
my ($field, $operator, $chart_id, $sequence, $joins) =
|
|
@$args{qw(field operator chart_id sequence joins)};
|
|
|
|
my $is_in_other_table;
|
|
if (my $join = USER_FIELDS->{$field}->{join}) {
|
|
$is_in_other_table = 1;
|
|
my $as = "${field}_$chart_id";
|
|
# Needed for setters.login_name and requestees.login_name.
|
|
# Otherwise when we try to join "profiles" below, we'd get
|
|
# something like "setters.login_name.login_name" in the "from".
|
|
$as =~ s/\./_/g;
|
|
# This helps implement the email1, email2, etc. parameters.
|
|
if ($chart_id =~ /default/) {
|
|
$as .= "_$sequence";
|
|
}
|
|
my $isprivate = USER_FIELDS->{$field}->{isprivate};
|
|
my $extra = ($isprivate and !$self->_user->is_insider)
|
|
? ["$as.isprivate = 0"] : [];
|
|
# We want to copy $join so as not to modify USER_FIELDS.
|
|
push(@$joins, { %$join, as => $as, extra => $extra });
|
|
my $search_field = USER_FIELDS->{$field}->{field};
|
|
$args->{full_field} = "$as.$search_field";
|
|
}
|
|
|
|
my $is_nullable = USER_FIELDS->{$field}->{nullable};
|
|
my $null_alternate = "''";
|
|
# When using a pronoun, we use the userid, and we don't have to
|
|
# join the profiles table.
|
|
if ($args->{value_is_id}) {
|
|
$null_alternate = 0;
|
|
}
|
|
elsif (substr($field, -9) eq '_realname') {
|
|
my $as = "name_${field}_$chart_id";
|
|
# For fields with periods in their name.
|
|
$as =~ s/\./_/;
|
|
my $join = {
|
|
table => 'profiles',
|
|
as => $as,
|
|
from => substr($args->{full_field}, 0, -9),
|
|
to => 'userid',
|
|
join => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
|
|
};
|
|
push(@$joins, $join);
|
|
$args->{full_field} = "$as.realname";
|
|
}
|
|
else {
|
|
my $as = "name_${field}_$chart_id";
|
|
# For fields with periods in their name.
|
|
$as =~ s/\./_/;
|
|
my $join = {
|
|
table => 'profiles',
|
|
as => $as,
|
|
from => $args->{full_field},
|
|
to => 'userid',
|
|
join => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
|
|
};
|
|
push(@$joins, $join);
|
|
$args->{full_field} = "$as.login_name";
|
|
}
|
|
|
|
# We COALESCE fields that can be NULL, to make "not"-style operators
|
|
# continue to work properly. For example, "qa_contact is not equal to bob"
|
|
# should also show bugs where the qa_contact is NULL. With COALESCE,
|
|
# it does.
|
|
if ($is_nullable) {
|
|
$args->{full_field} = "COALESCE($args->{full_field}, $null_alternate)";
|
|
}
|
|
|
|
# For fields whose values are stored in other tables, negation (NOT)
|
|
# only works properly if we put the condition into the JOIN instead
|
|
# of the WHERE.
|
|
if ($is_in_other_table) {
|
|
# Using the last join works properly whether we're searching based
|
|
# on userid or login_name.
|
|
my $last_join = $joins->[-1];
|
|
|
|
# For negative operators, the system we're using here
|
|
# only works properly if we reverse the operator and check IS NULL
|
|
# in the WHERE.
|
|
my $is_negative = $operator =~ /^(?:no|isempty)/ ? 1 : 0;
|
|
if ($is_negative) {
|
|
$args->{operator} = $self->_reverse_operator($operator);
|
|
}
|
|
$self->_do_operator_function($args);
|
|
push(@{ $last_join->{extra} }, $args->{term});
|
|
|
|
# For login_name searches, we only want a single join.
|
|
# So we create a subselect table out of our two joins. This makes
|
|
# negation (NOT) work properly for values that are in other
|
|
# tables.
|
|
if ($last_join->{table} eq 'profiles') {
|
|
pop @$joins;
|
|
$last_join->{join} = 'INNER';
|
|
my ($join_sql) = $self->_translate_join($last_join);
|
|
my $first_join = $joins->[-1];
|
|
my $as = $first_join->{as};
|
|
my $table = $first_join->{table};
|
|
my $columns = "bug_id";
|
|
$columns .= ",isprivate" if @{ $first_join->{extra} };
|
|
my $new_table = "SELECT DISTINCT $columns FROM $table AS $as $join_sql";
|
|
$first_join->{table} = "($new_table)";
|
|
# We always want to LEFT JOIN the generated table.
|
|
delete $first_join->{join};
|
|
# To support OR charts, we need multiple tables.
|
|
my $new_as = $first_join->{as} . "_$sequence";
|
|
$_ =~ s/\Q$as\E/$new_as/ foreach @{ $first_join->{extra} };
|
|
$first_join->{as} = $new_as;
|
|
$last_join = $first_join;
|
|
}
|
|
|
|
# If we're joining the first table (we're using a pronoun and
|
|
# searching by user id) then we need to check $other_table->{field}.
|
|
my $check_field = $last_join->{as} . '.bug_id';
|
|
if ($is_negative) {
|
|
$args->{term} = "$check_field IS NULL";
|
|
}
|
|
else {
|
|
$args->{term} = "$check_field IS NOT NULL";
|
|
}
|
|
}
|
|
}
|
|
|
|
# XXX This duplicates having Commenter as a search field.
|
|
sub _long_desc_changedby {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
|
|
|
|
my $table = "longdescs_$chart_id";
|
|
push(@$joins, { table => 'longdescs', as => $table });
|
|
my $user_id = $self->_get_user_id($value);
|
|
$args->{term} = "$table.who = $user_id";
|
|
|
|
# If the user is not part of the insiders group, they cannot see
|
|
# private comments
|
|
if (!$self->_user->is_insider) {
|
|
$args->{term} .= " AND $table.isprivate = 0";
|
|
}
|
|
}
|
|
|
|
sub _long_desc_changedbefore_after {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $operator, $value, $joins) =
|
|
@$args{qw(chart_id operator value joins)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
|
|
my $table = "longdescs_$chart_id";
|
|
my $sql_date = $dbh->quote(SqlifyDate($value));
|
|
my $join = {
|
|
table => 'longdescs',
|
|
as => $table,
|
|
extra => ["$table.bug_when $sql_operator $sql_date"],
|
|
};
|
|
push(@$joins, $join);
|
|
$args->{term} = "$table.bug_when IS NOT NULL";
|
|
|
|
# If the user is not part of the insiders group, they cannot see
|
|
# private comments
|
|
if (!$self->_user->is_insider) {
|
|
$args->{term} .= " AND $table.isprivate = 0";
|
|
}
|
|
}
|
|
|
|
sub _long_desc_nonchanged {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $operator, $value, $joins, $bugs_table) =
|
|
@$args{qw(chart_id operator value joins bugs_table)};
|
|
|
|
if ($operator =~ /^is(not)?empty$/) {
|
|
$args->{term} = $self->_multiselect_isempty($args, $operator eq 'isnotempty');
|
|
return;
|
|
}
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my $table = "longdescs_$chart_id";
|
|
my $join_args = {
|
|
chart_id => $chart_id,
|
|
sequence => $chart_id,
|
|
field => 'longdesc',
|
|
full_field => "$table.thetext",
|
|
operator => $operator,
|
|
value => $value,
|
|
all_values => $value,
|
|
quoted => $dbh->quote($value),
|
|
joins => [],
|
|
bugs_table => $bugs_table,
|
|
};
|
|
$self->_do_operator_function($join_args);
|
|
|
|
# If the user is not part of the insiders group, they cannot see
|
|
# private comments
|
|
if (!$self->_user->is_insider) {
|
|
$join_args->{term} .= " AND $table.isprivate = 0";
|
|
}
|
|
|
|
my $join = {
|
|
table => 'longdescs',
|
|
as => $table,
|
|
extra => [ $join_args->{term} ],
|
|
};
|
|
push(@$joins, $join);
|
|
|
|
$args->{term} = "$table.comment_id IS NOT NULL";
|
|
}
|
|
|
|
sub _content_matches {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $fields, $operator, $value) =
|
|
@$args{qw(chart_id joins fields operator value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
# "content" is an alias for columns containing text for which we
|
|
# can search a full-text index and retrieve results by relevance,
|
|
# currently just bug comments (and summaries to some degree).
|
|
# There's only one way to search a full-text index, so we only
|
|
# accept the "matches" operator, which is specific to full-text
|
|
# index searches.
|
|
|
|
# Add the fulltext table to the query so we can search on it.
|
|
my $table = "bugs_fulltext_$chart_id";
|
|
my $comments_col = "comments";
|
|
$comments_col = "comments_noprivate" unless $self->_user->is_insider;
|
|
push(@$joins, { table => 'bugs_fulltext', as => $table });
|
|
|
|
# Create search terms to add to the SELECT and WHERE clauses.
|
|
my ($term1, $rterm1) =
|
|
$dbh->sql_fulltext_search("$table.$comments_col", $value);
|
|
my ($term2, $rterm2) =
|
|
$dbh->sql_fulltext_search("$table.short_desc", $value);
|
|
$rterm1 = $term1 if !$rterm1;
|
|
$rterm2 = $term2 if !$rterm2;
|
|
|
|
# The term to use in the WHERE clause.
|
|
my $term = "$term1 OR $term2";
|
|
if ($operator =~ /not/i) {
|
|
$term = "NOT($term)";
|
|
}
|
|
$args->{term} = $term;
|
|
|
|
# In order to sort by relevance (in case the user requests it),
|
|
# we SELECT the relevance value so we can add it to the ORDER BY
|
|
# clause. Every time a new fulltext chart isadded, this adds more
|
|
# terms to the relevance sql.
|
|
#
|
|
# We build the relevance SQL by modifying the COLUMNS list directly,
|
|
# which is kind of a hack but works.
|
|
my $current = $self->COLUMNS->{'relevance'}->{name};
|
|
$current = $current ? "$current + " : '';
|
|
# For NOT searches, we just add 0 to the relevance.
|
|
my $select_term = $operator =~ /not/ ? 0 : "($current$rterm1 + $rterm2)";
|
|
$self->COLUMNS->{'relevance'}->{name} = $select_term;
|
|
}
|
|
|
|
sub _long_descs_count {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins) = @$args{qw(chart_id joins)};
|
|
my $table = "longdescs_count_$chart_id";
|
|
my $extra = $self->_user->is_insider ? "" : "WHERE isprivate = 0";
|
|
my $join = {
|
|
table => "(SELECT bug_id, COUNT(*) AS num"
|
|
. " FROM longdescs $extra GROUP BY bug_id)",
|
|
as => $table,
|
|
};
|
|
push(@$joins, $join);
|
|
$args->{full_field} = "${table}.num";
|
|
}
|
|
|
|
sub _work_time_changedby {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
|
|
|
|
my $table = "longdescs_$chart_id";
|
|
push(@$joins, { table => 'longdescs', as => $table });
|
|
my $user_id = $self->_get_user_id($value);
|
|
$args->{term} = "$table.who = $user_id AND $table.work_time != 0";
|
|
}
|
|
|
|
sub _work_time_changedbefore_after {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $operator, $value, $joins) =
|
|
@$args{qw(chart_id operator value joins)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my $table = "longdescs_$chart_id";
|
|
my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
|
|
my $sql_date = $dbh->quote(SqlifyDate($value));
|
|
my $join = {
|
|
table => 'longdescs',
|
|
as => $table,
|
|
extra => ["$table.work_time != 0",
|
|
"$table.bug_when $sql_operator $sql_date"],
|
|
};
|
|
push(@$joins, $join);
|
|
|
|
$args->{term} = "$table.bug_when IS NOT NULL";
|
|
}
|
|
|
|
sub _work_time {
|
|
my ($self, $args) = @_;
|
|
$self->_add_extra_column('actual_time');
|
|
$args->{full_field} = $self->COLUMNS->{actual_time}->{name};
|
|
}
|
|
|
|
sub _percentage_complete {
|
|
my ($self, $args) = @_;
|
|
|
|
$args->{full_field} = $self->COLUMNS->{percentage_complete}->{name};
|
|
|
|
# We need actual_time in _select_columns, otherwise we can't use
|
|
# it in the expression for searching percentage_complete.
|
|
$self->_add_extra_column('actual_time');
|
|
}
|
|
|
|
sub _last_visit_ts {
|
|
my ($self, $args) = @_;
|
|
|
|
$args->{full_field} = $self->COLUMNS->{last_visit_ts}->{name};
|
|
$self->_add_extra_column('last_visit_ts');
|
|
}
|
|
|
|
sub _last_visit_ts_invalid_operator {
|
|
my ($self, $args) = @_;
|
|
|
|
ThrowUserError('search_field_operator_invalid',
|
|
{ field => $args->{field},
|
|
operator => $args->{operator} });
|
|
}
|
|
|
|
sub _days_elapsed {
|
|
my ($self, $args) = @_;
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{full_field} = "(" . $dbh->sql_to_days('NOW()') . " - " .
|
|
$dbh->sql_to_days('bugs.delta_ts') . ")";
|
|
}
|
|
|
|
sub _component_nonchanged {
|
|
my ($self, $args) = @_;
|
|
|
|
$args->{full_field} = "components.name";
|
|
$self->_do_operator_function($args);
|
|
my $term = $args->{term};
|
|
$args->{term} = build_subselect("bugs.component_id",
|
|
"components.id", "components", $args->{term});
|
|
}
|
|
|
|
sub _product_nonchanged {
|
|
my ($self, $args) = @_;
|
|
|
|
# Generate the restriction condition
|
|
$args->{full_field} = "products.name";
|
|
$self->_do_operator_function($args);
|
|
my $term = $args->{term};
|
|
$args->{term} = build_subselect("bugs.product_id",
|
|
"products.id", "products", $term);
|
|
}
|
|
|
|
sub _alias_nonchanged {
|
|
my ($self, $args) = @_;
|
|
|
|
$args->{full_field} = "bugs_aliases.alias";
|
|
$self->_do_operator_function($args);
|
|
$args->{term} = build_subselect("bugs.bug_id",
|
|
"bugs_aliases.bug_id", "bugs_aliases", $args->{term});
|
|
}
|
|
|
|
sub _classification_nonchanged {
|
|
my ($self, $args) = @_;
|
|
my $joins = $args->{joins};
|
|
|
|
# This joins the right tables for us.
|
|
$self->_add_extra_column('product');
|
|
|
|
# Generate the restriction condition
|
|
$args->{full_field} = "classifications.name";
|
|
$self->_do_operator_function($args);
|
|
my $term = $args->{term};
|
|
$args->{term} = build_subselect("map_product.classification_id",
|
|
"classifications.id", "classifications", $term);
|
|
}
|
|
|
|
sub _nullable {
|
|
my ($self, $args) = @_;
|
|
my $field = $args->{full_field};
|
|
$args->{full_field} = "COALESCE($field, '')";
|
|
}
|
|
|
|
sub _nullable_int {
|
|
my ($self, $args) = @_;
|
|
my $field = $args->{full_field};
|
|
$args->{full_field} = "COALESCE($field, 0)";
|
|
}
|
|
|
|
sub _nullable_datetime {
|
|
my ($self, $args) = @_;
|
|
my $field = $args->{full_field};
|
|
my $empty = Bugzilla->dbh->quote(EMPTY_DATETIME);
|
|
$args->{full_field} = "COALESCE($field, $empty)";
|
|
}
|
|
|
|
sub _nullable_date {
|
|
my ($self, $args) = @_;
|
|
my $field = $args->{full_field};
|
|
my $empty = Bugzilla->dbh->quote(EMPTY_DATE);
|
|
$args->{full_field} = "COALESCE($field, $empty)";
|
|
}
|
|
|
|
sub _deadline {
|
|
my ($self, $args) = @_;
|
|
my $field = $args->{full_field};
|
|
# This makes "equals" searches work on all DBs (even on MySQL, which
|
|
# has a bug: http://bugs.mysql.com/bug.php?id=60324).
|
|
$args->{full_field} = Bugzilla->dbh->sql_date_format($field, '%Y-%m-%d');
|
|
$self->_nullable_datetime($args);
|
|
}
|
|
|
|
sub _owner_idle_time_greater_less {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $value, $operator) =
|
|
@$args{qw(chart_id joins value operator)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my $table = "idle_$chart_id";
|
|
my $quoted = $dbh->quote(SqlifyDate($value));
|
|
|
|
my $ld_table = "comment_$table";
|
|
my $act_table = "activity_$table";
|
|
my $comments_join = {
|
|
table => 'longdescs',
|
|
as => $ld_table,
|
|
from => 'assigned_to',
|
|
to => 'who',
|
|
extra => ["$ld_table.bug_when > $quoted"],
|
|
};
|
|
my $activity_join = {
|
|
table => 'bugs_activity',
|
|
as => $act_table,
|
|
from => 'assigned_to',
|
|
to => 'who',
|
|
extra => ["$act_table.bug_when > $quoted"]
|
|
};
|
|
|
|
push(@$joins, $comments_join, $activity_join);
|
|
|
|
if ($operator =~ /greater/) {
|
|
$args->{term} =
|
|
"$ld_table.who IS NULL AND $act_table.who IS NULL";
|
|
} else {
|
|
$args->{term} =
|
|
"($ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL)";
|
|
}
|
|
}
|
|
|
|
sub _multiselect_negative {
|
|
my ($self, $args) = @_;
|
|
my ($field, $operator) = @$args{qw(field operator)};
|
|
|
|
$args->{operator} = $self->_reverse_operator($operator);
|
|
$args->{term} = $self->_multiselect_term($args, 1);
|
|
}
|
|
|
|
sub _multiselect_multiple {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $field, $operator, $value)
|
|
= @$args{qw(chart_id field operator value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
# We want things like "cf_multi_select=two+words" to still be
|
|
# considered a search for two separate words, unless we're using
|
|
# anyexact. (_all_values would consider that to be one "word" with a
|
|
# space in it, because it's not in the Boolean Charts).
|
|
my @words = $operator eq 'anyexact' ? $self->_all_values($args)
|
|
: split(/[\s,]+/, $value);
|
|
|
|
my @terms;
|
|
foreach my $word (@words) {
|
|
next if $word eq '';
|
|
$args->{value} = $word;
|
|
$args->{quoted} = $dbh->quote($word);
|
|
push(@terms, $self->_multiselect_term($args));
|
|
}
|
|
|
|
# The spacing in the joins helps make the resulting SQL more readable.
|
|
if ($operator =~ /^any/) {
|
|
$args->{term} = join("\n OR ", @terms);
|
|
}
|
|
else {
|
|
$args->{term} = join("\n AND ", @terms);
|
|
}
|
|
}
|
|
|
|
sub _flagtypes_nonchanged {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $operator, $value, $joins, $bugs_table, $condition) =
|
|
@$args{qw(chart_id operator value joins bugs_table condition)};
|
|
|
|
if ($operator =~ /^is(not)?empty$/) {
|
|
$args->{term} = $self->_multiselect_isempty($args, $operator eq 'isnotempty');
|
|
return;
|
|
}
|
|
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
# For 'not' operators, we need to negate the whole term.
|
|
# If you search for "Flags" (does not contain) "approval+" we actually want
|
|
# to return *bugs* that don't contain an approval+ flag. Without rewriting
|
|
# the negation we'll search for *flags* which don't contain approval+.
|
|
if ($operator =~ s/^not//) {
|
|
$args->{operator} = $operator;
|
|
$condition->operator($operator);
|
|
$condition->negate(1);
|
|
}
|
|
|
|
my $subselect_args = {
|
|
chart_id => $chart_id,
|
|
sequence => $chart_id,
|
|
field => 'flagtypes.name',
|
|
full_field => $dbh->sql_string_concat("flagtypes_$chart_id.name", "flags_$chart_id.status"),
|
|
operator => $operator,
|
|
value => $value,
|
|
all_values => $value,
|
|
quoted => $dbh->quote($value),
|
|
joins => [],
|
|
bugs_table => "bugs_$chart_id",
|
|
};
|
|
$self->_do_operator_function($subselect_args);
|
|
my $subselect_term = $subselect_args->{term};
|
|
|
|
# don't call build_subselect as this must run as a true sub-select
|
|
$args->{term} = "EXISTS (
|
|
SELECT 1
|
|
FROM $bugs_table bugs_$chart_id
|
|
LEFT JOIN attachments AS attachments_$chart_id
|
|
ON bugs_$chart_id.bug_id = attachments_$chart_id.bug_id
|
|
LEFT JOIN flags AS flags_$chart_id
|
|
ON bugs_$chart_id.bug_id = flags_$chart_id.bug_id
|
|
AND (flags_$chart_id.attach_id = attachments_$chart_id.attach_id
|
|
OR flags_$chart_id.attach_id IS NULL)
|
|
LEFT JOIN flagtypes AS flagtypes_$chart_id
|
|
ON flags_$chart_id.type_id = flagtypes_$chart_id.id
|
|
WHERE bugs_$chart_id.bug_id = $bugs_table.bug_id
|
|
AND $subselect_term
|
|
)";
|
|
}
|
|
|
|
sub _multiselect_nonchanged {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $field, $operator) =
|
|
@$args{qw(chart_id joins field operator)};
|
|
$args->{term} = $self->_multiselect_term($args)
|
|
}
|
|
|
|
sub _multiselect_table {
|
|
my ($self, $args) = @_;
|
|
my ($field, $chart_id) = @$args{qw(field chart_id)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
if ($field eq 'keywords') {
|
|
$args->{full_field} = 'keyworddefs.name';
|
|
return "keywords INNER JOIN keyworddefs".
|
|
" ON keywords.keywordid = keyworddefs.id";
|
|
}
|
|
elsif ($field eq 'tag') {
|
|
$args->{full_field} = 'tag.name';
|
|
return "bug_tag INNER JOIN tag ON bug_tag.tag_id = tag.id AND user_id = "
|
|
. ($self->_sharer_id || $self->_user->id);
|
|
}
|
|
elsif ($field eq 'bug_group') {
|
|
$args->{full_field} = 'groups.name';
|
|
return "bug_group_map INNER JOIN groups
|
|
ON bug_group_map.group_id = groups.id";
|
|
}
|
|
elsif ($field eq 'blocked' or $field eq 'dependson') {
|
|
my $select = $field eq 'blocked' ? 'dependson' : 'blocked';
|
|
$args->{_select_field} = $select;
|
|
$args->{full_field} = $field;
|
|
return "dependencies";
|
|
}
|
|
elsif ($field eq 'longdesc') {
|
|
$args->{_extra_where} = " AND isprivate = 0"
|
|
if !$self->_user->is_insider;
|
|
$args->{full_field} = 'thetext';
|
|
return "longdescs";
|
|
}
|
|
elsif ($field eq 'longdescs.isprivate') {
|
|
ThrowUserError('auth_failure', { action => 'search',
|
|
object => 'bug_fields',
|
|
field => 'longdescs.isprivate' })
|
|
if !$self->_user->is_insider;
|
|
$args->{full_field} = 'isprivate';
|
|
return "longdescs";
|
|
}
|
|
elsif ($field =~ /^attachments/) {
|
|
$args->{_extra_where} = " AND isprivate = 0"
|
|
if !$self->_user->is_insider;
|
|
$field =~ /^attachments\.(.+)$/;
|
|
$args->{full_field} = $1;
|
|
return "attachments";
|
|
}
|
|
elsif ($field eq 'attach_data.thedata') {
|
|
$args->{_extra_where} = " AND attachments.isprivate = 0"
|
|
if !$self->_user->is_insider;
|
|
return "attachments INNER JOIN attach_data "
|
|
. " ON attachments.attach_id = attach_data.id"
|
|
}
|
|
elsif ($field eq 'comment_tag') {
|
|
$args->{_extra_where} = " AND longdescs.isprivate = 0"
|
|
if !$self->_user->is_insider;
|
|
$args->{full_field} = 'longdescs_tags.tag';
|
|
return "longdescs INNER JOIN longdescs_tags".
|
|
" ON longdescs.comment_id = longdescs_tags.comment_id";
|
|
}
|
|
my $table = "bug_$field";
|
|
$args->{full_field} = "bug_$field.value";
|
|
return $table;
|
|
}
|
|
|
|
sub _multiselect_term {
|
|
my ($self, $args, $not) = @_;
|
|
my ($operator) = $args->{operator};
|
|
my $value = $args->{value} || '';
|
|
# 'empty' operators require special handling
|
|
return $self->_multiselect_isempty($args, $not)
|
|
if ($operator =~ /^is(not)?empty$/ || $value eq '---');
|
|
my $table = $self->_multiselect_table($args);
|
|
$self->_do_operator_function($args);
|
|
my $term = $args->{term};
|
|
$term .= $args->{_extra_where} || '';
|
|
my $select = $args->{_select_field} || 'bug_id';
|
|
return build_subselect("$args->{bugs_table}.bug_id", $select, $table, $term, $not);
|
|
}
|
|
|
|
# We can't use the normal operator_functions to build isempty queries which
|
|
# join to different tables.
|
|
sub _multiselect_isempty {
|
|
my ($self, $args, $not) = @_;
|
|
my ($field, $operator, $joins, $chart_id) = @$args{qw(field operator joins chart_id)};
|
|
my $dbh = Bugzilla->dbh;
|
|
$operator = $self->_reverse_operator($operator) if $not;
|
|
$not = $operator eq 'isnotempty' ? 'NOT' : '';
|
|
|
|
if ($field eq 'keywords') {
|
|
push @$joins, {
|
|
table => 'keywords',
|
|
as => "keywords_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
};
|
|
return "keywords_$chart_id.bug_id IS $not NULL";
|
|
}
|
|
elsif ($field eq 'bug_group') {
|
|
push @$joins, {
|
|
table => 'bug_group_map',
|
|
as => "bug_group_map_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
};
|
|
return "bug_group_map_$chart_id.bug_id IS $not NULL";
|
|
}
|
|
elsif ($field eq 'flagtypes.name') {
|
|
push @$joins, {
|
|
table => 'flags',
|
|
as => "flags_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
};
|
|
return "flags_$chart_id.bug_id IS $not NULL";
|
|
}
|
|
elsif ($field eq 'blocked' or $field eq 'dependson') {
|
|
my $to = $field eq 'blocked' ? 'dependson' : 'blocked';
|
|
push @$joins, {
|
|
table => 'dependencies',
|
|
as => "dependencies_$chart_id",
|
|
from => 'bug_id',
|
|
to => $to,
|
|
};
|
|
return "dependencies_$chart_id.$to IS $not NULL";
|
|
}
|
|
elsif ($field eq 'longdesc') {
|
|
my @extra = ( "longdescs_$chart_id.type != " . CMT_HAS_DUPE );
|
|
push @extra, "longdescs_$chart_id.isprivate = 0"
|
|
unless $self->_user->is_insider;
|
|
push @$joins, {
|
|
table => 'longdescs',
|
|
as => "longdescs_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
extra => \@extra,
|
|
};
|
|
return $not
|
|
? "longdescs_$chart_id.thetext != ''"
|
|
: "longdescs_$chart_id.thetext = ''";
|
|
}
|
|
elsif ($field eq 'longdescs.isprivate') {
|
|
ThrowUserError('search_field_operator_invalid', { field => $field,
|
|
operator => $operator });
|
|
}
|
|
elsif ($field =~ /^attachments\.(.+)/) {
|
|
my $sub_field = $1;
|
|
if ($sub_field eq 'description' || $sub_field eq 'filename' || $sub_field eq 'mimetype') {
|
|
# can't be null/empty
|
|
return $not ? '1=1' : '1=2';
|
|
} else {
|
|
# all other fields which get here are boolean
|
|
ThrowUserError('search_field_operator_invalid', { field => $field,
|
|
operator => $operator });
|
|
}
|
|
}
|
|
elsif ($field eq 'attach_data.thedata') {
|
|
push @$joins, {
|
|
table => 'attachments',
|
|
as => "attachments_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
extra => [ $self->_user->is_insider ? '' : "attachments_$chart_id.isprivate = 0" ],
|
|
};
|
|
push @$joins, {
|
|
table => 'attach_data',
|
|
as => "attach_data_$chart_id",
|
|
from => "attachments_$chart_id.attach_id",
|
|
to => 'id',
|
|
};
|
|
return "attach_data_$chart_id.thedata IS $not NULL";
|
|
}
|
|
elsif ($field eq 'tag') {
|
|
push @$joins, {
|
|
table => 'bug_tag',
|
|
as => "bug_tag_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
};
|
|
push @$joins, {
|
|
table => 'tag',
|
|
as => "tag_$chart_id",
|
|
from => "bug_tag_$chart_id.tag_id",
|
|
to => 'id',
|
|
extra => [ "tag_$chart_id.user_id = " . ($self->_sharer_id || $self->_user->id) ],
|
|
};
|
|
return "tag_$chart_id.id IS $not NULL";
|
|
}
|
|
elsif ($self->_multi_select_fields->{$field}) {
|
|
push @$joins, {
|
|
table => "bug_$field",
|
|
as => "bug_${field}_$chart_id",
|
|
from => 'bug_id',
|
|
to => 'bug_id',
|
|
};
|
|
return "bug_${field}_$chart_id.bug_id IS $not NULL";
|
|
}
|
|
}
|
|
|
|
###############################
|
|
# Standard Operator Functions #
|
|
###############################
|
|
|
|
sub _simple_operator {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $quoted, $operator) =
|
|
@$args{qw(full_field quoted operator)};
|
|
my $sql_operator = SIMPLE_OPERATORS->{$operator};
|
|
$args->{term} = "$full_field $sql_operator $quoted";
|
|
}
|
|
|
|
sub _casesubstring {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $value) = @$args{qw(full_field value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{term} = $dbh->sql_like($value, $full_field);
|
|
}
|
|
|
|
sub _substring {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $value) = @$args{qw(full_field value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{term} = $dbh->sql_ilike($value, $full_field);
|
|
}
|
|
|
|
sub _notsubstring {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $value) = @$args{qw(full_field value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{term} = $dbh->sql_not_ilike($value, $full_field);
|
|
}
|
|
|
|
sub _regexp {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $quoted) = @$args{qw(full_field quoted)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{term} = $dbh->sql_regexp($full_field, $quoted);
|
|
}
|
|
|
|
sub _notregexp {
|
|
my ($self, $args) = @_;
|
|
my ($full_field, $quoted) = @$args{qw(full_field quoted)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
$args->{term} = $dbh->sql_not_regexp($full_field, $quoted);
|
|
}
|
|
|
|
sub _anyexact {
|
|
my ($self, $args) = @_;
|
|
my ($field, $full_field) = @$args{qw(field full_field)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my @list = $self->_all_values($args, ',');
|
|
@list = map { $self->_quote_unless_numeric($args, $_) } @list;
|
|
|
|
if (@list) {
|
|
$args->{term} = $dbh->sql_in($full_field, \@list);
|
|
}
|
|
else {
|
|
$args->{term} = '';
|
|
}
|
|
}
|
|
|
|
sub _anywordsubstr {
|
|
my ($self, $args) = @_;
|
|
|
|
my @terms = $self->_substring_terms($args);
|
|
$args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : '';
|
|
}
|
|
|
|
sub _allwordssubstr {
|
|
my ($self, $args) = @_;
|
|
|
|
my @terms = $self->_substring_terms($args);
|
|
$args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : '';
|
|
}
|
|
|
|
sub _nowordssubstr {
|
|
my ($self, $args) = @_;
|
|
$self->_anywordsubstr($args);
|
|
my $term = $args->{term};
|
|
$args->{term} = "NOT($term)";
|
|
}
|
|
|
|
sub _anywords {
|
|
my ($self, $args) = @_;
|
|
|
|
my @terms = $self->_word_terms($args);
|
|
# Because _word_terms uses AND, we need to parenthesize its terms
|
|
# if there are more than one.
|
|
@terms = map("($_)", @terms) if scalar(@terms) > 1;
|
|
$args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : '';
|
|
}
|
|
|
|
sub _allwords {
|
|
my ($self, $args) = @_;
|
|
|
|
my @terms = $self->_word_terms($args);
|
|
$args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : '';
|
|
}
|
|
|
|
sub _nowords {
|
|
my ($self, $args) = @_;
|
|
$self->_anywords($args);
|
|
my $term = $args->{term};
|
|
$args->{term} = "NOT($term)";
|
|
}
|
|
|
|
sub _changedbefore_changedafter {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $field, $operator, $value) =
|
|
@$args{qw(chart_id joins field operator value)};
|
|
my $dbh = Bugzilla->dbh;
|
|
|
|
my $field_object = $self->_chart_fields->{$field}
|
|
|| ThrowCodeError("invalid_field_name", { field => $field });
|
|
|
|
# Asking when creation_ts changed is just asking when the bug was created.
|
|
if ($field_object->name eq 'creation_ts') {
|
|
$args->{operator} =
|
|
$operator eq 'changedbefore' ? 'lessthaneq' : 'greaterthaneq';
|
|
return $self->_do_operator_function($args);
|
|
}
|
|
|
|
my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
|
|
my $field_id = $field_object->id;
|
|
# Charts on changed* fields need to be field-specific. Otherwise,
|
|
# OR chart rows make no sense if they contain multiple fields.
|
|
my $table = "act_${field_id}_$chart_id";
|
|
|
|
my $sql_date = $dbh->quote(SqlifyDate($value));
|
|
my $join = {
|
|
table => 'bugs_activity',
|
|
as => $table,
|
|
extra => ["$table.fieldid = $field_id",
|
|
"$table.bug_when $sql_operator $sql_date"],
|
|
};
|
|
|
|
$args->{term} = "$table.bug_when IS NOT NULL";
|
|
$self->_changed_security_check($args, $join);
|
|
push(@$joins, $join);
|
|
}
|
|
|
|
sub _changedfrom_changedto {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $field, $operator, $quoted) =
|
|
@$args{qw(chart_id joins field operator quoted)};
|
|
|
|
my $column = ($operator =~ /from/) ? 'removed' : 'added';
|
|
my $field_object = $self->_chart_fields->{$field}
|
|
|| ThrowCodeError("invalid_field_name", { field => $field });
|
|
my $field_id = $field_object->id;
|
|
my $table = "act_${field_id}_$chart_id";
|
|
my $join = {
|
|
table => 'bugs_activity',
|
|
as => $table,
|
|
extra => ["$table.fieldid = $field_id",
|
|
"$table.$column = $quoted"],
|
|
};
|
|
|
|
$args->{term} = "$table.bug_when IS NOT NULL";
|
|
$self->_changed_security_check($args, $join);
|
|
push(@$joins, $join);
|
|
}
|
|
|
|
sub _changedby {
|
|
my ($self, $args) = @_;
|
|
my ($chart_id, $joins, $field, $operator, $value) =
|
|
@$args{qw(chart_id joins field operator value)};
|
|
|
|
my $field_object = $self->_chart_fields->{$field}
|
|
|| ThrowCodeError("invalid_field_name", { field => $field });
|
|
my $field_id = $field_object->id;
|
|
my $table = "act_${field_id}_$chart_id";
|
|
my $user_id = $self->_get_user_id($value);
|
|
my $join = {
|
|
table => 'bugs_activity',
|
|
as => $table,
|
|
extra => ["$table.fieldid = $field_id",
|
|
"$table.who = $user_id"],
|
|
};
|
|
|
|
$args->{term} = "$table.bug_when IS NOT NULL";
|
|
$self->_changed_security_check($args, $join);
|
|
push(@$joins, $join);
|
|
}
|
|
|
|
sub _changed_security_check {
|
|
my ($self, $args, $join) = @_;
|
|
my ($chart_id, $field) = @$args{qw(chart_id field)};
|
|
|
|
my $field_object = $self->_chart_fields->{$field}
|
|
|| ThrowCodeError("invalid_field_name", { field => $field });
|
|
my $field_id = $field_object->id;
|
|
|
|
# If the user is not part of the insiders group, they cannot see
|
|
# changes to attachments (including attachment flags) that are private
|
|
if ($field =~ /^(?:flagtypes\.name$|attach)/ and !$self->_user->is_insider) {
|
|
$join->{then_to} = {
|
|
as => "attach_${field_id}_$chart_id",
|
|
table => 'attachments',
|
|
from => "act_${field_id}_$chart_id.attach_id",
|
|
to => 'attach_id',
|
|
};
|
|
|
|
$args->{term} .= " AND COALESCE(attach_${field_id}_$chart_id.isprivate, 0) = 0";
|
|
}
|
|
}
|
|
|
|
sub _isempty {
|
|
my ($self, $args) = @_;
|
|
my $full_field = $args->{full_field};
|
|
$args->{term} = "$full_field IS NULL OR $full_field = " . $self->_empty_value($args->{field});
|
|
}
|
|
|
|
sub _isnotempty {
|
|
my ($self, $args) = @_;
|
|
my $full_field = $args->{full_field};
|
|
$args->{term} = "$full_field IS NOT NULL AND $full_field != " . $self->_empty_value($args->{field});
|
|
}
|
|
|
|
sub _empty_value {
|
|
my ($self, $field) = @_;
|
|
my $field_obj = $self->_chart_fields->{$field};
|
|
return "0" if $field_obj->type == FIELD_TYPE_BUG_ID;
|
|
return Bugzilla->dbh->quote(EMPTY_DATETIME) if $field_obj->type == FIELD_TYPE_DATETIME;
|
|
return Bugzilla->dbh->quote(EMPTY_DATE) if $field_obj->type == FIELD_TYPE_DATE;
|
|
return "''";
|
|
}
|
|
|
|
######################
|
|
# Public Subroutines #
|
|
######################
|
|
|
|
# Validate that the query type is one we can deal with
|
|
sub IsValidQueryType
|
|
{
|
|
my ($queryType) = @_;
|
|
if (grep { $_ eq $queryType } qw(specific advanced)) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
# Splits out "asc|desc" from a sort order item.
|
|
sub split_order_term {
|
|
my $fragment = shift;
|
|
$fragment =~ /^(.+?)(?:\s+(ASC|DESC))?$/i;
|
|
my ($column_name, $direction) = (lc($1), uc($2 || ''));
|
|
return wantarray ? ($column_name, $direction) : $column_name;
|
|
}
|
|
|
|
# Used to translate old SQL fragments from buglist.cgi's "order" argument
|
|
# into our modern field IDs.
|
|
sub _translate_old_column {
|
|
my ($self, $column) = @_;
|
|
# All old SQL fragments have a period in them somewhere.
|
|
return $column if $column !~ /\./;
|
|
|
|
if ($column =~ /\bAS\s+(\w+)$/i) {
|
|
return $1;
|
|
}
|
|
# product, component, classification, assigned_to, qa_contact, reporter
|
|
elsif ($column =~ /map_(\w+?)s?\.(login_)?name/i) {
|
|
return $1;
|
|
}
|
|
|
|
# If it doesn't match the regexps above, check to see if the old
|
|
# SQL fragment matches the SQL of an existing column
|
|
foreach my $key (%{ $self->COLUMNS }) {
|
|
next unless exists $self->COLUMNS->{$key}->{name};
|
|
return $key if $self->COLUMNS->{$key}->{name} eq $column;
|
|
}
|
|
|
|
return $column;
|
|
}
|
|
|
|
1;
|
|
|
|
__END__
|
|
|
|
=head1 NAME
|
|
|
|
Bugzilla::Search - Provides methods to run queries against bugs.
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
use Bugzilla::Search;
|
|
|
|
my $search = new Bugzilla::Search({'fields' => \@fields,
|
|
'params' => \%search_criteria,
|
|
'sharer' => $sharer_id,
|
|
'user' => $user_obj,
|
|
'allow_unlimited' => 1});
|
|
|
|
my $data = $search->data;
|
|
my ($data, $extra_data) = $search->data;
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
Search.pm represents a search object. It's the single way to collect
|
|
data about bugs in a secure way. The list of bugs matching criteria
|
|
defined by the caller are filtered based on the user privileges.
|
|
|
|
=head1 METHODS
|
|
|
|
=head2 new
|
|
|
|
=over
|
|
|
|
=item B<Description>
|
|
|
|
Create a Bugzilla::Search object.
|
|
|
|
=item B<Params>
|
|
|
|
=over
|
|
|
|
=item C<fields>
|
|
|
|
An arrayref representing the bug attributes for which data is desired.
|
|
Legal attributes are listed in the fielddefs DB table. At least one field
|
|
must be defined, typically the 'bug_id' field.
|
|
|
|
=item C<params>
|
|
|
|
A hashref representing search criteria. Each key => value pair represents
|
|
a search criteria, where the key is the search field and the value is the
|
|
value for this field. At least one search criteria must be defined if the
|
|
'search_allow_no_criteria' parameter is turned off, else an error is thrown.
|
|
|
|
=item C<sharer>
|
|
|
|
When a saved search is shared by a user, this is their user ID.
|
|
|
|
=item C<user>
|
|
|
|
A L<Bugzilla::User> object representing the user to whom the data is addressed.
|
|
All security checks are done based on this user object, so it's not safe
|
|
to share results of the query with other users as not all users have the
|
|
same privileges or have the same role for all bugs in the list. If this
|
|
parameter is not defined, then the currently logged in user is taken into
|
|
account. If no user is logged in, then only public bugs will be returned.
|
|
|
|
=item C<allow_unlimited>
|
|
|
|
If set to a true value, the number of bugs retrieved by the query is not
|
|
limited.
|
|
|
|
=back
|
|
|
|
=item B<Returns>
|
|
|
|
A L<Bugzilla::Search> object.
|
|
|
|
=back
|
|
|
|
=head2 data
|
|
|
|
=over
|
|
|
|
=item B<Description>
|
|
|
|
Returns bugs matching search criteria passed to C<new()>.
|
|
|
|
=item B<Params>
|
|
|
|
None
|
|
|
|
=item B<Returns>
|
|
|
|
In scalar context, this method returns a reference to a list of bugs.
|
|
Each item of the list represents a bug, which is itself a reference to
|
|
a list where each item represents a bug attribute, in the same order as
|
|
specified in the C<fields> parameter of C<new()>.
|
|
|
|
In list context, this methods also returns a reference to a list containing
|
|
references to hashes. For each hash, two keys are defined: C<sql> contains
|
|
the SQL query which has been executed, and C<time> contains the time spent
|
|
to execute the SQL query, in seconds. There can be either a single hash, or
|
|
two hashes if two SQL queries have been executed sequentially to get all the
|
|
required data.
|
|
|
|
=back
|
|
|
|
=head1 B<Methods in need of POD>
|
|
|
|
=over
|
|
|
|
=item invalid_order_columns
|
|
|
|
=item COLUMN_JOINS
|
|
|
|
=item split_order_term
|
|
|
|
=item SqlifyDate
|
|
|
|
=item REPORT_COLUMNS
|
|
|
|
=item pronoun
|
|
|
|
=item COLUMNS
|
|
|
|
=item order
|
|
|
|
=item search_description
|
|
|
|
=item IsValidQueryType
|
|
|
|
=item build_subselect
|
|
|
|
=item do_search_function
|
|
|
|
=item boolean_charts_to_custom_search
|
|
|
|
=back
|