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