diff options
Diffstat (limited to 'Bugzilla/Search.pm')
-rw-r--r-- | Bugzilla/Search.pm | 5176 |
1 files changed, 2596 insertions, 2580 deletions
diff --git a/Bugzilla/Search.pm b/Bugzilla/Search.pm index 0694dd98c..a6e85f35b 100644 --- a/Bugzilla/Search.pm +++ b/Bugzilla/Search.pm @@ -13,8 +13,8 @@ use warnings; use parent qw(Exporter); @Bugzilla::Search::EXPORT = qw( - IsValidQueryType - split_order_term + IsValidQueryType + split_order_term ); use Bugzilla::Error; @@ -135,311 +135,275 @@ use constant NUMBER_REGEX => qr/ # 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, + 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 => "<=", + 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 + 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 + changedafter + changedbefore + changedfrom + changedto + regexp + notregexp ); # These operators ignore the entered value use constant NO_VALUE_OPERATORS => qw( - isempty - isnotempty + 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, + 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, + # 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; - } + 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; } - return $map; -}; + 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' }, - }, + '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', + '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', - } - }, + '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'], + classification => ['product'], + percentage_complete => ['actual_time', 'remaining_time'], }; # This describes tables that must be joined when you want to display @@ -447,109 +411,81 @@ use constant COLUMN_DEPENDS => { # 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; -}; + 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 +# 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: # @@ -559,7 +495,7 @@ sub COLUMN_JOINS { # 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. @@ -570,150 +506,149 @@ sub COLUMN_JOINS { # 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; + 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"; + 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'); } - - # 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 }; + 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'}; + # The short_short_desc column is identical to short_desc + $columns{'short_short_desc'} = $columns{'short_desc'}; - Bugzilla::Hook::process('buglist_columns', { columns => \%columns }); + Bugzilla::Hook::process('buglist_columns', {columns => \%columns}); - $cache->{search_columns}->{$user->id} = \%columns; - return $cache->{search_columns}->{$user->id}; + $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); - } + my $invocant = shift; + my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user; - foreach my $name (@no_report_columns) { - delete $columns->{$name}; - } - return $columns; + 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 + alias + blocked + bug_id + dependson + flagtypes.name + keywords + longdescs.count + percentage_complete + tag ); ############### @@ -722,27 +657,27 @@ use constant GROUP_BY_SKIP => qw( # 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; + 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; } @@ -751,148 +686,156 @@ sub new { #################### 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]; + 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; + 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}; + $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'}; + 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++; + 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; + 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; + my ($self) = @_; + return $self->_valid_order; } ###################### @@ -901,49 +844,48 @@ sub order { # Fields that are legal for boolean charts of any kind. sub _chart_fields { - my ($self) = @_; + my ($self) = @_; - if (!$self->{chart_fields}) { - my $chart_fields = Bugzilla->fields({ by_name => 1 }); + 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; + if (!$self->_user->is_timetracker) { + foreach my $tt_field (TIMETRACKING_FIELDS) { + delete $chart_fields->{$tt_field}; + } } - return $self->{chart_fields}; + $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}; + 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} } + 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} } ############################## @@ -952,81 +894,84 @@ sub _sharer_id { $_[0]->{sharer} } # 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'} || [] } } +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} }; + 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); + 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} }; + 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); + 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); } - # Remove duplicated columns. - $self->{select_columns} = [uniq @select_columns]; - return @{ $self->{select_columns} }; + 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); + 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; } - return @sql_fields; + push(@sql_fields, $sql); + } + return @sql_fields; } ################################ @@ -1035,85 +980,83 @@ sub _sql_select { # The "order" that was requested by the consumer, exactly as it was # requested. -sub _input_order { @{ $_[0]->{'order'} || [] } } +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; + 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; + my ($self) = @_; + return map { (split_order_term($_))[0] } $self->_valid_order; } sub _validate_order_column { - my ($self, $order_item) = @_; + my ($self, $order_item) = @_; - # Translate old column names - my ($field, $direction) = split_order_term($order_item); - $field = $self->_translate_old_column($field); + # 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}); + # 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}); + # 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"; + $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}; + 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} }; + 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; + 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; } ############################# @@ -1121,32 +1064,30 @@ sub _translate_order_by_column { ############################# 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 ''; + 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 ''; } ############################ @@ -1154,176 +1095,176 @@ sub _sql_limit { ############################ 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"; + 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"}; } - return $join_info ? $join_info : (); + } + 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; + 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); - } + 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; + } + 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); - } + 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; + } + 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); + my ($self) = @_; + my $user = $self->_user; + my @joins; + return () if $self->{_no_security_check}; - 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); + 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') . ")"]; } - - return @joins; + + 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); + 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; + 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; } ############################# @@ -1336,54 +1277,60 @@ sub _translate_join { # 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; + 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)"; + if (Bugzilla->params->{'useqacontact'}) { + $security_term .= " OR bugs.qa_contact = $userid"; } + $security_term = "($security_term)"; + } - push(@where, $security_term); + push(@where, $security_term); - return @where; + 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; + 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; } ################################ @@ -1393,40 +1340,40 @@ sub _sql_where { # 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); - } + my ($self) = @_; - # 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)); + # 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}; + 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}; } ############################################## @@ -1435,244 +1382,255 @@ sub _skip_group_by { # 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}; - } + 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; + 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); + 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); } - return @{$clause->children} ? $clause : undef; + $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; - } + 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; + 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 ($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); - } + 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; + 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); + 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); } - return @{$clause->children} ? $clause : undef; + $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'}; - } + 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); - } + 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); } - return @result; + elsif (grep { $_->name eq $item } @$valid) { + push(@result, $item); + } + } + return @result; } ###################################### @@ -1680,239 +1638,247 @@ sub _valid_values { ###################################### 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); + 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; + 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; + 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); + 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; + 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' + 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); - - 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); + $new_clause->negate($params->{"n$id"}); + $current_clause->add($new_clause); + push(@clause_stack, $current_clause); + $current_clause = $new_clause; + next; } - - # 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; + 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; + 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); + 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; + 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; + # 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; } ################################## @@ -1922,121 +1888,126 @@ sub _convert_unicode_characters { # 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; - } + my ($self, $args) = @_; + my ($field, $operator) = @$args{qw(field operator)}; - # 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 }); - } + 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); + 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"; + 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}; - } + 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; + return $search_func; } sub _get_operator_field_override { - my $self = shift; - my $cache = Bugzilla->request_cache; + my $self = shift; + my $cache = Bugzilla->request_cache; - return $cache->{operator_field_override} - if defined $cache->{operator_field_override}; + 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 }); + 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}; + $cache->{operator_field_override} = \%operator_field_override; + return $cache->{operator_field_override}; } sub _get_column_joins { - my $self = shift; - my $cache = Bugzilla->request_cache; + my $self = shift; + my $cache = Bugzilla->request_cache; - return $cache->{column_joins} if defined $cache->{column_joins}; + 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 }); + 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}; + $cache->{column_joins} = \%column_joins; + return $cache->{column_joins}; } ########################### @@ -2048,47 +2019,49 @@ sub _get_column_joins { # 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); + 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); + 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 @@ -2096,68 +2069,69 @@ sub build_subselect { # 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; + 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; + 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; + # 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; + 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; } ##################################### @@ -2165,109 +2139,118 @@ sub _word_terms { ##################################### sub _timestamp_translate { - my ($self, $ignore_time, $args) = @_; - my $value = $args->{value}; - my $dbh = Bugzilla->dbh; - - # Force parsing of all dates & times, so that we filter weird values out - # from users. - #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); + my ($self, $ignore_time, $args) = @_; + my $value = $args->{value}; + my $dbh = Bugzilla->dbh; + + # Force parsing of all dates & times, so that we filter weird values out + # from users. + #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, @_); + 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'; - } + 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, @_); + 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); + 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); } ###################################### @@ -2275,104 +2258,109 @@ sub SqlifyDate { ###################################### 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"; + my ($noun, $user) = (@_); + if ($noun eq "%user%") { + if ($user->id) { + return $user->id; } - if ($noun eq "%qacontact%") { - return "COALESCE(bugs.qa_contact,0)"; + 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 }); + ThrowUserError('illegal_pronoun', {pronoun => $noun}); } sub _contact_pronoun { - my ($self, $args) = @_; - my $value = $args->{value}; - my $user = $self->_user; + 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; - } + 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"; - } + 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) = @_; + my ($self, $value) = @_; - if ($value =~ /^%\w+%$/) { - return pronoun($value, $self->_user); - } - return login_to_id($value, THROW_ERROR); + if ($value =~ /^%\w+%$/) { + return pronoun($value, $self->_user); + } + return login_to_id($value, THROW_ERROR); } ##################################################################### @@ -2380,546 +2368,556 @@ sub _get_user_id { ##################################################################### sub _invalid_combination { - my ($self, $args) = @_; - my ($field, $operator) = @$args{qw(field operator)}; - ThrowUserError('search_field_operator_invalid', - { field => $field, operator => $operator }); + 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 ($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"; - 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; + # 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); } - 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"; + $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 { - 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"; - } + $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"; - } + 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"; - } + 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"; + 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; + 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"; + 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"; + 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"; + 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}; + 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}; + my ($self, $args) = @_; - # 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'); + $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) = @_; + my ($self, $args) = @_; - $args->{full_field} = $self->COLUMNS->{last_visit_ts}->{name}; - $self->_add_extra_column('last_visit_ts'); + $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) = @_; + my ($self, $args) = @_; - ThrowUserError('search_field_operator_invalid', - { field => $args->{field}, - operator => $args->{operator} }); + 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') . ")"; + 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}); + 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); + 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) = @_; + 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}); + $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); + 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, '')"; + 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)"; + 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)"; + 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)"; + 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); + 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)"; - } + 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)}; + my ($self, $args) = @_; + my ($field, $operator) = @$args{qw(field operator)}; - $args->{operator} = $self->_reverse_operator($operator); - $args->{term} = $self->_multiselect_term($args, 1); + $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); - } + 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 ( + 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 @@ -2936,209 +2934,224 @@ sub _flagtypes_nonchanged { } 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) + 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 + 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; + } + 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); + 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"; + 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"; + } } ############################### @@ -3146,234 +3159,236 @@ sub _multiselect_isempty { ############################### 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"; + 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; + my ($self, $args) = @_; + my ($full_field, $value) = @$args{qw(full_field value)}; + my $dbh = Bugzilla->dbh; - $args->{term} = $dbh->sql_like($value, $full_field); + $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; + my ($self, $args) = @_; + my ($full_field, $value) = @$args{qw(full_field value)}; + my $dbh = Bugzilla->dbh; - $args->{term} = $dbh->sql_ilike($value, $full_field); + $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; + 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); + $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); + 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); + 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} = ''; - } + 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 ($self, $args) = @_; - my @terms = $self->_substring_terms($args); - $args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : ''; + my @terms = $self->_substring_terms($args); + $args->{term} = @terms ? '(' . join("\n\tOR ", @terms) . ')' : ''; } sub _allwordssubstr { - my ($self, $args) = @_; + my ($self, $args) = @_; - my @terms = $self->_substring_terms($args); - $args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : ''; + 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)"; + my ($self, $args) = @_; + $self->_anywordsubstr($args); + my $term = $args->{term}; + $args->{term} = "NOT($term)"; } sub _anywords { - my ($self, $args) = @_; + my ($self, $args) = @_; + + my @terms = $self->_word_terms($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) . ')' : ''; + # 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 ($self, $args) = @_; - my @terms = $self->_word_terms($args); - $args->{term} = @terms ? '(' . join("\n\tAND ", @terms) . ')' : ''; + 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)"; + 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"], - }; + my ($self, $args) = @_; + my ($chart_id, $joins, $field, $operator, $value) + = @$args{qw(chart_id joins field operator value)}; + my $dbh = Bugzilla->dbh; - $args->{term} = "$table.bug_when IS NOT NULL"; - $self->_changed_security_check($args, $join); - push(@$joins, $join); + 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); + 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); + 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"; - } + 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}); + 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}); + 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 "''"; + 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 "''"; } ###################### @@ -3381,46 +3396,47 @@ sub _empty_value { ###################### # 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; +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; + 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 !~ /\./; + my ($self, $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; - } + # 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; + return $column; } 1; |