1 # -*- Mode: perl; indent-tabs-mode: nil -*-
3 # The contents of this file are subject to the Mozilla Public
4 # License Version 1.1 (the "License"); you may not use this file
5 # except in compliance with the License. You may obtain a copy of
6 # the License at http://www.mozilla.org/MPL/
8 # Software distributed under the License is distributed on an "AS
9 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
10 # implied. See the License for the specific language governing
11 # rights and limitations under the License.
13 # The Original Code is the Bugzilla Bug Tracking System.
15 # The Initial Developer of the Original Code is Netscape Communications
16 # Corporation. Portions created by Netscape are
17 # Copyright (C) 1998 Netscape Communications Corporation. All
20 # Contributor(s): Gervase Markham <gerv@gerv.net>
21 # Terry Weissman <terry@mozilla.org>
22 # Dan Mosedale <dmose@mozilla.org>
23 # Stephan Niemz <st.n@gmx.net>
24 # Andreas Franke <afranke@mathweb.org>
25 # Myk Melez <myk@mozilla.org>
26 # Michael Schindler <michael@compressconsult.com>
27 # Max Kanat-Alexander <mkanat@bugzilla.org>
28 # Joel Peshkin <bugreport@peshkin.net>
29 # Lance Larsh <lance.larsh@oracle.com>
30 # Jesse Clark <jjclark1982@gmail.com>
31 # Rémi Zara <remi_zara@mac.com>
32 # Reed Loden <reed@reedloden.com>
36 package Bugzilla::Search;
37 use base qw(Exporter);
38 @Bugzilla::Search::EXPORT = qw(
46 use Bugzilla::Constants;
50 use Bugzilla::Search::Clause;
51 use Bugzilla::Search::Condition qw(condition);
53 use Bugzilla::Keyword;
58 use Scalar::Util qw(blessed);
59 use List::MoreUtils qw(all part uniq);
60 use POSIX qw(INT_MAX);
61 use Storable qw(dclone);
63 # Description Of Boolean Charts
64 # -----------------------------
66 # A boolean chart is a way of representing the terms in a logical
67 # expression. Bugzilla builds SQL queries depending on how you enter
68 # terms into the boolean chart. Boolean charts are represented in
69 # urls as three-tuples of (chart id, row, column). The query form
70 # (query.cgi) may contain an arbitrary number of boolean charts where
71 # each chart represents a clause in a SQL query.
73 # The query form starts out with one boolean chart containing one
74 # row and one column. Extra rows can be created by pressing the
75 # AND button at the bottom of the chart. Extra columns are created
76 # by pressing the OR button at the right end of the chart. Extra
77 # charts are created by pressing "Add another boolean chart".
79 # Each chart consists of an arbitrary number of rows and columns.
80 # The terms within a row are ORed together. The expressions represented
81 # by each row are ANDed together. The expressions represented by each
82 # chart are ANDed together.
84 # ----------------------
85 # | col2 | col2 | col3 |
86 # --------------|------|------|
87 # | row1 | a1 | a2 | |
88 # |------|------|------|------| => ((a1 OR a2) AND (b1 OR b2 OR b3) AND (c1))
89 # | row2 | b1 | b2 | b3 |
90 # |------|------|------|------|
92 # -----------------------------
97 # | row1 | d1 | => (d1)
100 # Together, these two charts represent a SQL expression like this
101 # SELECT blah FROM blah WHERE ( (a1 OR a2)AND(b1 OR b2 OR b3)AND(c1)) AND (d1)
103 # The terms within a single row of a boolean chart are all constraints
104 # on a single piece of data. If you're looking for a bug that has two
105 # different people cc'd on it, then you need to use two boolean charts.
106 # This will find bugs with one CC matching 'foo@blah.org' and and another
107 # CC matching 'bar@blah.org'.
109 # --------------------------------------------------------------
112 # --------------------------------------------------------------
116 # If you try to do this query by pressing the AND button in the
117 # original boolean chart then what you'll get is an expression that
118 # looks for a single CC where the login name is both "foo@blah.org",
119 # and "bar@blah.org". This is impossible.
121 # --------------------------------------------------------------
127 # --------------------------------------------------------------
133 # When doing searches, NULL datetimes are treated as this date.
134 use constant EMPTY_DATETIME => '1970-01-01 00:00:00';
136 # This is the regex for real numbers from Regexp::Common, modified to be
138 use constant NUMBER_REGEX => qr/
139 ^[+-]? # A sign, optionally.
141 (?=\d|\.) # Then either a digit or "."
142 \d* # Followed by many other digits
144 \. # Followed possibly by some decimal places
148 (?: # Followed possibly by an exponent.
156 # If you specify a search type in the boolean charts, this describes
157 # which operator maps to which internal function here.
158 use constant OPERATORS => {
159 equals => \&_simple_operator,
160 notequals => \&_simple_operator,
161 casesubstring => \&_casesubstring,
162 substring => \&_substring,
163 substr => \&_substring,
164 notsubstring => \&_notsubstring,
166 notregexp => \&_notregexp,
167 lessthan => \&_simple_operator,
168 lessthaneq => \&_simple_operator,
169 matches => sub { ThrowUserError("search_content_without_matches"); },
170 notmatches => sub { ThrowUserError("search_content_without_matches"); },
171 greaterthan => \&_simple_operator,
172 greaterthaneq => \&_simple_operator,
173 anyexact => \&_anyexact,
174 anywordssubstr => \&_anywordsubstr,
175 allwordssubstr => \&_allwordssubstr,
176 nowordssubstr => \&_nowordssubstr,
177 anywords => \&_anywords,
178 allwords => \&_allwords,
179 nowords => \&_nowords,
180 changedbefore => \&_changedbefore_changedafter,
181 changedafter => \&_changedbefore_changedafter,
182 changedfrom => \&_changedfrom_changedto,
183 changedto => \&_changedfrom_changedto,
184 changedby => \&_changedby,
187 # Some operators are really just standard SQL operators, and are
188 # all implemented by the _simple_operator function, which uses this
190 use constant SIMPLE_OPERATORS => {
194 greaterthaneq => '>=',
199 # Most operators just reverse by removing or adding "not" from/to them.
200 # However, some operators reverse in a different way, so those are listed
202 use constant OPERATOR_REVERSE => {
203 nowords => 'anywords',
204 nowordssubstr => 'anywordssubstr',
205 anywords => 'nowords',
206 anywordssubstr => 'nowordssubstr',
207 lessthan => 'greaterthaneq',
208 lessthaneq => 'greaterthan',
209 greaterthan => 'lessthaneq',
210 greaterthaneq => 'lessthan',
211 # The following don't currently have reversals:
212 # casesubstring, anyexact, allwords, allwordssubstr
215 # For these operators, even if a field is numeric (is_numeric returns true),
216 # we won't treat the input like a number.
217 use constant NON_NUMERIC_OPERATORS => qw(
226 use constant MULTI_SELECT_OVERRIDE => {
227 notequals => \&_multiselect_negative,
228 notregexp => \&_multiselect_negative,
229 notsubstring => \&_multiselect_negative,
230 nowords => \&_multiselect_negative,
231 nowordssubstr => \&_multiselect_negative,
233 allwords => \&_multiselect_multiple,
234 allwordssubstr => \&_multiselect_multiple,
235 anyexact => \&_multiselect_multiple,
236 anywords => \&_multiselect_multiple,
237 anywordssubstr => \&_multiselect_multiple,
239 _non_changed => \&_multiselect_nonchanged,
242 use constant OPERATOR_FIELD_OVERRIDE => {
244 'attachments.submitter' => {
245 _non_changed => \&_user_nonchanged,
248 _non_changed => \&_user_nonchanged,
251 _non_changed => \&_user_nonchanged,
254 _non_changed => \&_user_nonchanged,
257 _non_changed => \&_user_nonchanged,
259 'requestees.login_name' => {
260 _non_changed => \&_user_nonchanged,
262 'setters.login_name' => {
263 _non_changed => \&_user_nonchanged,
266 _non_changed => \&_user_nonchanged,
270 alias => { _non_changed => \&_nullable },
271 'attach_data.thedata' => MULTI_SELECT_OVERRIDE,
272 # We check all attachment fields against this.
273 attachments => MULTI_SELECT_OVERRIDE,
274 blocked => MULTI_SELECT_OVERRIDE,
275 bug_file_loc => { _non_changed => \&_nullable },
276 bug_group => MULTI_SELECT_OVERRIDE,
278 _non_changed => \&_classification_nonchanged,
281 _non_changed => \&_component_nonchanged,
284 matches => \&_content_matches,
285 notmatches => \&_content_matches,
286 _default => sub { ThrowUserError("search_content_without_matches"); },
289 _default => \&_days_elapsed,
291 dependson => MULTI_SELECT_OVERRIDE,
292 keywords => MULTI_SELECT_OVERRIDE,
293 'flagtypes.name' => {
294 _non_changed => \&_flagtypes_nonchanged,
297 changedby => \&_long_desc_changedby,
298 changedbefore => \&_long_desc_changedbefore_after,
299 changedafter => \&_long_desc_changedbefore_after,
300 _non_changed => \&_long_desc_nonchanged,
302 'longdescs.count' => {
303 changedby => \&_long_desc_changedby,
304 changedbefore => \&_long_desc_changedbefore_after,
305 changedafter => \&_long_desc_changedbefore_after,
306 changedfrom => \&_invalid_combination,
307 changedto => \&_invalid_combination,
308 _default => \&_long_descs_count,
310 'longdescs.isprivate' => MULTI_SELECT_OVERRIDE,
312 greaterthan => \&_owner_idle_time_greater_less,
313 greaterthaneq => \&_owner_idle_time_greater_less,
314 lessthan => \&_owner_idle_time_greater_less,
315 lessthaneq => \&_owner_idle_time_greater_less,
316 _default => \&_invalid_combination,
319 _non_changed => \&_product_nonchanged,
321 tag => MULTI_SELECT_OVERRIDE,
323 # Timetracking Fields
324 deadline => { _non_changed => \&_deadline },
325 percentage_complete => {
326 _non_changed => \&_percentage_complete,
329 changedby => \&_work_time_changedby,
330 changedbefore => \&_work_time_changedbefore_after,
331 changedafter => \&_work_time_changedbefore_after,
332 _default => \&_work_time,
336 FIELD_TYPE_FREETEXT, { _non_changed => \&_nullable },
337 FIELD_TYPE_BUG_ID, { _non_changed => \&_nullable_int },
338 FIELD_TYPE_DATETIME, { _non_changed => \&_nullable_datetime },
339 FIELD_TYPE_TEXTAREA, { _non_changed => \&_nullable },
340 FIELD_TYPE_MULTI_SELECT, MULTI_SELECT_OVERRIDE,
341 FIELD_TYPE_BUG_URLS, MULTI_SELECT_OVERRIDE,
344 # These are fields where special action is taken depending on the
345 # *value* passed in to the chart, sometimes.
346 use constant SPECIAL_PARSING => {
347 # Pronoun Fields (Ones that can accept %user%, etc.)
348 assigned_to => \&_contact_pronoun,
350 commenter => \&_commenter_pronoun,
351 qa_contact => \&_contact_pronoun,
352 reporter => \&_contact_pronoun,
354 # Date Fields that accept the 1d, 1w, 1m, 1y, etc. format.
355 creation_ts => \&_timestamp_translate,
356 deadline => \&_timestamp_translate,
357 delta_ts => \&_timestamp_translate,
360 # Information about fields that represent "users", used by _user_nonchanged.
361 # There are other user fields than the ones listed here, but those use
362 # defaults in _user_nonchanged.
363 use constant USER_FIELDS => {
364 'attachments.submitter' => {
365 field => 'submitter_id',
366 join => { table => 'attachments' },
371 join => { table => 'cc' },
375 join => { table => 'longdescs', join => 'INNER' },
381 'requestees.login_name' => {
383 field => 'requestee_id',
384 join => { table => 'flags' },
386 'setters.login_name' => {
387 field => 'setter_id',
388 join => { table => 'flags' },
392 # Backwards compatibility for times that we changed the names of fields
394 use constant FIELD_MAP => {
395 'attachments.thedata' => 'attach_data.thedata',
396 bugidtype => 'bug_id_type',
397 changedin => 'days_elapsed',
398 long_desc => 'longdesc',
401 # Some fields are not sorted on themselves, but on other fields.
402 # We need to have a list of these fields and what they map to.
403 use constant SPECIAL_ORDER => {
404 'target_milestone' => {
405 order => ['map_target_milestone.sortkey','map_target_milestone.value'],
407 table => 'milestones',
408 from => 'target_milestone',
410 extra => ['bugs.product_id = map_target_milestone.product_id'],
416 # Certain columns require other columns to come before them
417 # in _select_columns, and should be put there if they're not there.
418 use constant COLUMN_DEPENDS => {
419 classification => ['product'],
420 percentage_complete => ['actual_time', 'remaining_time'],
423 # This describes tables that must be joined when you want to display
424 # certain columns in the buglist. For the most part, Search.pm uses
425 # DB::Schema to figure out what needs to be joined, but for some
426 # fields it needs a little help.
427 use constant COLUMN_JOINS => {
429 table => '(SELECT bug_id, SUM(work_time) AS total'
430 . ' FROM longdescs GROUP BY bug_id)',
434 from => 'assigned_to',
446 from => 'qa_contact',
451 from => 'component_id',
453 table => 'components',
457 from => 'product_id',
463 table => 'classifications',
464 from => 'map_product.classification_id',
468 'flagtypes.name' => {
471 extra => ['map_flags.attach_id IS NULL'],
473 as => 'map_flagtypes',
474 table => 'flagtypes',
475 from => 'map_flags.type_id',
482 as => 'map_keyworddefs',
483 table => 'keyworddefs',
484 from => 'map_keywords.keywordid',
488 'longdescs.count' => {
489 table => 'longdescs',
494 # This constant defines the columns that can be selected in a query
495 # and/or displayed in a bug list. Column records include the following
498 # 1. id: a unique identifier by which the column is referred in code;
500 # 2. name: The name of the column in the database (may also be an expression
501 # that returns the value of the column);
503 # 3. title: The title of the column as displayed to users.
505 # Note: There are a few hacks in the code that deviate from these definitions.
506 # In particular, the redundant short_desc column is removed when the
507 # client requests "all" columns.
509 # This is really a constant--that is, once it's been called once, the value
510 # will always be the same unless somebody adds a new custom field. But
511 # we have to do a lot of work inside the subroutine to get the data,
512 # and we don't want it to happen at compile time, so we have it as a
515 my $invocant = shift;
516 my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
517 my $dbh = Bugzilla->dbh;
518 my $cache = Bugzilla->request_cache;
520 if (defined $cache->{search_columns}->{$user->id}) {
521 return $cache->{search_columns}->{$user->id};
524 # These are columns that don't exist in fielddefs, but are valid buglist
525 # columns. (Also see near the bottom of this function for the definition
526 # of short_short_desc.)
528 relevance => { title => 'Relevance' },
529 assigned_to_realname => { title => 'Assignee' },
530 reporter_realname => { title => 'Reporter' },
531 qa_contact_realname => { title => 'QA Contact' },
534 # Next we define columns that have special SQL instead of just something
535 # like "bugs.bug_id".
536 my $total_time = "(map_actual_time.total + bugs.remaining_time)";
538 deadline => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'),
539 actual_time => 'map_actual_time.total',
541 # "FLOOR" is in there to turn this into an integer, making searches
542 # totally predictable. Otherwise you get floating-point numbers that
543 # are rather hard to search reliably if you're asking for exact
545 percentage_complete =>
546 "(CASE WHEN $total_time = 0"
548 . " ELSE FLOOR(100 * (map_actual_time.total / $total_time))"
551 'flagtypes.name' => $dbh->sql_group_concat('DISTINCT '
552 . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')),
554 'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'),
556 'longdescs.count' => 'COUNT(DISTINCT map_longdescs_count.comment_id)',
559 # Backward-compatibility for old field names. Goes new_name => old_name.
560 # These are here and not in translate_old_column because the rest of the
561 # code actually still uses the old names, while the fielddefs table uses
562 # the new names (which is not the case for the fields handled by
563 # translate_old_column).
565 creation_ts => 'opendate',
566 delta_ts => 'changeddate',
567 work_time => 'actual_time',
570 # Fields that are email addresses
571 my @email_fields = qw(assigned_to reporter qa_contact);
572 # Other fields that are stored in the bugs table as an id, but
573 # should be displayed using their name.
574 my @id_fields = qw(product component classification);
576 foreach my $col (@email_fields) {
577 my $sql = "map_${col}.login_name";
579 $sql = $dbh->sql_string_until($sql, $dbh->quote('@'));
581 $special_sql{$col} = $sql;
582 $columns{"${col}_realname"}->{name} = "map_${col}.realname";
585 foreach my $col (@id_fields) {
586 $special_sql{$col} = "map_${col}.name";
589 # Do the actual column-getting from fielddefs, now.
590 my @fields = @{ Bugzilla->fields({ obsolete => 0, buglist => 1 }) };
591 foreach my $field (@fields) {
592 my $id = $field->name;
593 $id = $old_names{$id} if exists $old_names{$id};
595 if (exists $special_sql{$id}) {
596 $sql = $special_sql{$id};
598 elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
599 $sql = $dbh->sql_group_concat(
600 'DISTINCT map_' . $field->name . '.value');
603 $sql = 'bugs.' . $field->name;
605 $columns{$id} = { name => $sql, title => $field->description };
608 # The short_short_desc column is identical to short_desc
609 $columns{'short_short_desc'} = $columns{'short_desc'};
611 Bugzilla::Hook::process('buglist_columns', { columns => \%columns });
613 $cache->{search_columns}->{$user->id} = \%columns;
614 return $cache->{search_columns}->{$user->id};
618 my $invocant = shift;
619 my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
621 my $columns = dclone(blessed($invocant) ? $invocant->COLUMNS : COLUMNS);
622 # There's no reason to support reporting on unique fields.
623 # Also, some other fields don't make very good reporting axises,
624 # or simply don't work with the current reporting system.
625 my @no_report_columns =
626 qw(bug_id alias short_short_desc opendate changeddate
627 flagtypes.name keywords relevance);
629 # Multi-select fields are not currently supported.
630 my @multi_selects = @{Bugzilla->fields(
631 { obsolete => 0, type => FIELD_TYPE_MULTI_SELECT })};
632 push(@no_report_columns, map { $_->name } @multi_selects);
634 # If you're not a time-tracker, you can't use time-tracking
636 if (!$user->is_timetracker) {
637 push(@no_report_columns, TIMETRACKING_FIELDS);
640 foreach my $name (@no_report_columns) {
641 delete $columns->{$name};
646 # These are fields that never go into the GROUP BY on any DB. bug_id
647 # is here because it *always* goes into the GROUP BY as the first item,
648 # so it should be skipped when determining extra GROUP BY columns.
649 use constant GROUP_BY_SKIP => qw(
661 # Note that the params argument may be modified by Bugzilla::Search
663 my $invocant = shift;
664 my $class = ref($invocant) || $invocant;
667 bless($self, $class);
668 $self->{'user'} ||= Bugzilla->user;
670 # There are certain behaviors of the CGI "Vars" hash that we don't want.
671 # In particular, if you put a single-value arrayref into it, later you
672 # get back out a string, which breaks anyexact charts (because they
673 # need arrays even for individual items, or we will re-trigger bug 67036).
675 # We can't just untie the hash--that would give us a hash with no values.
676 # We have to manually copy the hash into a new one, and we have to always
677 # do it, because there's no way to know if we were passed a tied hash
679 my $params_in = $self->_params;
680 my %params = map { $_ => $params_in->{$_} } keys %$params_in;
681 $self->{params} = \%params;
693 return $self->{sql} if $self->{sql};
694 my $dbh = Bugzilla->dbh;
696 my ($joins, $clause) = $self->_charts_to_conditions();
698 if (!$clause->as_string
699 && !Bugzilla->params->{'search_allow_no_criteria'}
700 && !$self->{allow_unlimited})
702 ThrowUserError('buglist_parameters_required');
705 my $select = join(', ', $self->_sql_select);
706 my $from = $self->_sql_from($joins);
707 my $where = $self->_sql_where($clause);
708 my $group_by = $dbh->sql_group_by($self->_sql_group_by);
709 my $order_by = $self->_sql_order_by
710 ? "\nORDER BY " . join(', ', $self->_sql_order_by) : '';
711 my $limit = $self->_sql_limit;
712 $limit = "\n$limit" if $limit;
718 $group_by$order_by$limit
720 $self->{sql} = $query;
724 sub search_description {
725 my ($self, $params) = @_;
726 my $desc = $self->{'search_description'} ||= [];
728 push(@$desc, $params);
730 # Make sure that the description has actually been generated if
731 # people are asking for the whole thing.
735 return $self->{'search_description'};
738 sub boolean_charts_to_custom_search {
739 my ($self, $cgi_buffer) = @_;
740 my @as_params = $self->_boolean_charts->as_params;
742 # We need to start our new ids after the last custom search "f" id.
743 # We can just pick the last id in the array because they are sorted
745 my $last_id = ($self->_field_ids)[-1];
746 my $count = defined($last_id) ? $last_id + 1 : 0;
747 foreach my $param_set (@as_params) {
748 foreach my $name (keys %$param_set) {
749 my $value = $param_set->{$name};
750 next if !defined $value;
751 $cgi_buffer->param($name . $count, $value);
757 ######################
758 # Internal Accessors #
759 ######################
761 # Fields that are legal for boolean charts of any kind.
765 if (!$self->{chart_fields}) {
766 my $chart_fields = Bugzilla->fields({ by_name => 1 });
768 if (!$self->_user->is_timetracker) {
769 foreach my $tt_field (TIMETRACKING_FIELDS) {
770 delete $chart_fields->{$tt_field};
773 $self->{chart_fields} = $chart_fields;
775 return $self->{chart_fields};
778 # There are various places in Search.pm that we need to know the list of
779 # valid multi-select fields--or really, fields that are stored like
780 # multi-selects, which includes BUG_URLS fields.
781 sub _multi_select_fields {
783 $self->{multi_select_fields} ||= Bugzilla->fields({
785 type => [FIELD_TYPE_MULTI_SELECT, FIELD_TYPE_BUG_URLS]});
786 return $self->{multi_select_fields};
789 # $self->{params} contains values that could be undef, could be a string,
790 # or could be an arrayref. Sometimes we want that value as an array,
793 my ($self, $name) = @_;
794 my $value = $self->_params->{$name};
795 if (!defined $value) {
798 if (ref($value) eq 'ARRAY') {
804 sub _params { $_[0]->{params} }
805 sub _user { return $_[0]->{user} }
806 sub _sharer_id { $_[0]->{sharer} }
808 ##############################
809 # Internal Accessors: SELECT #
810 ##############################
812 # These are the fields the user has chosen to display on the buglist,
813 # exactly as they were passed to new().
814 sub _input_columns { @{ $_[0]->{'fields'} || [] } }
816 # These are columns that are also going to be in the SELECT for one reason
817 # or another, but weren't actually requested by the caller.
820 # Everything that's going to be in the ORDER BY must also be
822 push(@{ $self->{extra_columns} }, $self->_input_order_columns);
823 return @{ $self->{extra_columns} };
826 # For search functions to modify extra_columns. It doesn't matter if
827 # people push the same column onto this array multiple times, because
828 # _select_columns will call "uniq" on its final result.
829 sub _add_extra_column {
830 my ($self, $column) = @_;
831 push(@{ $self->{extra_columns} }, $column);
834 # These are the columns that we're going to be actually SELECTing.
835 sub _display_columns {
837 return @{ $self->{display_columns} } if $self->{display_columns};
839 # Do not alter the list from _input_columns at all, even if there are
840 # duplicated columns. Those are passed by the caller, and the caller
841 # expects to get them back in the exact same order.
842 my @columns = $self->_input_columns;
844 # Only add columns which are not already listed.
845 my %list = map { $_ => 1 } @columns;
846 foreach my $column ($self->_extra_columns) {
847 push(@columns, $column) unless $list{$column}++;
849 $self->{display_columns} = \@columns;
850 return @{ $self->{display_columns} };
853 # These are the columns that are involved in the query.
854 sub _select_columns {
856 return @{ $self->{select_columns} } if $self->{select_columns};
859 foreach my $column ($self->_display_columns) {
860 if (my $add_first = COLUMN_DEPENDS->{$column}) {
861 push(@select_columns, @$add_first);
863 push(@select_columns, $column);
865 # Remove duplicated columns.
866 $self->{select_columns} = [uniq @select_columns];
867 return @{ $self->{select_columns} };
870 # This takes _display_columns and translates it into the actual SQL that
871 # will go into the SELECT clause.
875 foreach my $column ($self->_display_columns) {
877 # Aliases cannot contain dots in them. We convert them to underscores.
879 my $sql = $self->COLUMNS->{$column}->{name} . " AS $alias";
880 push(@sql_fields, $sql);
885 ################################
886 # Internal Accessors: ORDER BY #
887 ################################
889 # The "order" that was requested by the consumer, exactly as it was
891 sub _input_order { @{ $_[0]->{'order'} || [] } }
892 # The input order with just the column names, and no ASC or DESC.
893 sub _input_order_columns {
895 return map { (split_order_term($_))[0] } $self->_input_order;
898 # A hashref that describes all the special stuff that has to be done
899 # for various fields if they go into the ORDER BY clause.
902 return $self->{special_order} if $self->{special_order};
904 my %special_order = %{ SPECIAL_ORDER() };
905 my $select_fields = Bugzilla->fields({ type => FIELD_TYPE_SINGLE_SELECT });
906 foreach my $field (@$select_fields) {
907 next if $field->is_abnormal;
908 my $name = $field->name;
909 $special_order{$name} = {
910 order => ["map_$name.sortkey", "map_$name.value"],
913 from => "bugs.$name",
919 $self->{special_order} = \%special_order;
920 return $self->{special_order};
925 if (!$self->{sql_order_by}) {
926 my @order_by = map { $self->_translate_order_by_column($_) }
928 $self->{sql_order_by} = \@order_by;
930 return @{ $self->{sql_order_by} };
933 sub _translate_order_by_column {
934 my ($self, $order_by_item) = @_;
936 my ($field, $direction) = split_order_term($order_by_item);
938 $direction = '' if lc($direction) eq 'asc';
939 my $special_order = $self->_special_order->{$field}->{order};
940 # Standard fields have underscores in their SELECT alias instead
941 # of a period (because aliases can't have periods).
943 my @items = $special_order ? @$special_order : $field;
944 if (lc($direction) eq 'desc') {
945 @items = map { "$_ DESC" } @items;
950 #############################
951 # Internal Accessors: LIMIT #
952 #############################
956 my $limit = $self->_params->{limit};
957 my $offset = $self->_params->{offset};
959 my $max_results = Bugzilla->params->{'max_search_results'};
960 if (!$self->{allow_unlimited} && (!$limit || $limit > $max_results)) {
961 $limit = $max_results;
964 if (defined($offset) && !$limit) {
967 if (defined $limit) {
968 detaint_natural($limit)
969 || ThrowCodeError('param_must_be_numeric',
970 { function => 'Bugzilla::Search::new',
972 if (defined $offset) {
973 detaint_natural($offset)
974 || ThrowCodeError('param_must_be_numeric',
975 { function => 'Bugzilla::Search::new',
976 param => 'offset' });
978 return Bugzilla->dbh->sql_limit($limit, $offset);
983 ############################
984 # Internal Accessors: FROM #
985 ############################
988 my ($self, $field) = @_;
989 # The _realname fields require the same join as the username fields.
990 $field =~ s/_realname$//;
991 my $column_joins = $self->_get_column_joins();
992 my $join_info = $column_joins->{$field};
994 # Don't allow callers to modify the constant.
995 $join_info = dclone($join_info);
998 if ($self->_multi_select_fields->{$field}) {
999 $join_info = { table => "bug_$field" };
1002 if ($join_info and !$join_info->{as}) {
1003 $join_info = dclone($join_info);
1004 $join_info->{as} = "map_$field";
1006 return $join_info ? $join_info : ();
1009 # Sometimes we join the same table more than once. In this case, we
1010 # want to AND all the various critiera that were used in both joins.
1011 sub _combine_joins {
1012 my ($self, $joins) = @_;
1014 while(my $join = shift @$joins) {
1015 my $name = $join->{as};
1016 my ($others_like_me, $the_rest) = part { $_->{as} eq $name ? 0 : 1 }
1018 if ($others_like_me) {
1019 my $from = $join->{from};
1020 my $to = $join->{to};
1021 # Sanity check to make sure that we have the same from and to
1022 # for all the same-named joins.
1024 all { $_->{from} eq $from } @$others_like_me
1025 or die "Not all same-named joins have identical 'from': "
1026 . Dumper($join, $others_like_me);
1029 all { $_->{to} eq $to } @$others_like_me
1030 or die "Not all same-named joins have identical 'to': "
1031 . Dumper($join, $others_like_me);
1034 # We don't need to call uniq here--translate_join will do that
1036 my @conditions = map { @{ $_->{extra} || [] } }
1037 ($join, @$others_like_me);
1038 $join->{extra} = \@conditions;
1041 push(@result, $join);
1047 # Takes all the "then_to" items and just puts them as the next item in
1048 # the array. Right now this only does one level of "then_to", but we
1049 # could re-write this to handle then_to recursively if we need more levels.
1050 sub _extract_then_to {
1051 my ($self, $joins) = @_;
1053 foreach my $join (@$joins) {
1054 push(@result, $join);
1055 if (my $then_to = $join->{then_to}) {
1056 push(@result, $then_to);
1062 # JOIN statements for the SELECT and ORDER BY columns. This should not be
1063 # called until the moment it is needed, because _select_columns might be
1064 # modified by the charts.
1065 sub _select_order_joins {
1068 foreach my $field ($self->_select_columns) {
1069 my @column_join = $self->_column_join($field);
1070 push(@joins, @column_join);
1072 foreach my $field ($self->_input_order_columns) {
1073 my $join_info = $self->_special_order->{$field}->{join};
1075 # Don't let callers modify SPECIAL_ORDER.
1076 $join_info = dclone($join_info);
1077 if (!$join_info->{as}) {
1078 $join_info->{as} = "map_$field";
1080 push(@joins, $join_info);
1086 # These are the joins that are *always* in the FROM clause.
1087 sub _standard_joins {
1089 my $user = $self->_user;
1092 my $security_join = {
1093 table => 'bug_group_map',
1094 as => 'security_map',
1096 push(@joins, $security_join);
1099 $security_join->{extra} =
1100 ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"];
1102 my $security_cc_join = {
1104 as => 'security_cc',
1105 extra => ['security_cc.who = ' . $user->id],
1107 push(@joins, $security_cc_join);
1114 my ($self, $joins_input) = @_;
1115 my @joins = ($self->_standard_joins, $self->_select_order_joins,
1117 @joins = $self->_extract_then_to(\@joins);
1118 @joins = $self->_combine_joins(\@joins);
1119 my @join_sql = map { $self->_translate_join($_) } @joins;
1120 return "bugs\n" . join("\n", @join_sql);
1123 # This takes a join data structure and turns it into actual JOIN SQL.
1124 sub _translate_join {
1125 my ($self, $join_info) = @_;
1127 die "join with no table: " . Dumper($join_info) if !$join_info->{table};
1128 die "join with no 'as': " . Dumper($join_info) if !$join_info->{as};
1130 my $from_table = "bugs";
1131 my $from = $join_info->{from} || "bug_id";
1132 if ($from =~ /^(\w+)\.(\w+)$/) {
1133 ($from_table, $from) = ($1, $2);
1135 my $table = $join_info->{table};
1136 my $name = $join_info->{as};
1137 my $to = $join_info->{to} || "bug_id";
1138 my $join = $join_info->{join} || 'LEFT';
1139 my @extra = @{ $join_info->{extra} || [] };
1142 # If a term contains ORs, we need to put parens around the condition.
1143 # This is a pretty weak test, but it's actually OK to put parens
1144 # around too many things.
1145 @extra = map { $_ =~ /\bOR\b/i ? "($_)" : $_ } @extra;
1146 my $extra_condition = join(' AND ', uniq @extra);
1147 if ($extra_condition) {
1148 $extra_condition = " AND $extra_condition";
1151 my @join_sql = "$join JOIN $table AS $name"
1152 . " ON $from_table.$from = $name.$to$extra_condition";
1156 #############################
1157 # Internal Accessors: WHERE #
1158 #############################
1160 # Note: There's also quite a bit of stuff that affects the WHERE clause
1161 # in the "Internal Accessors: Boolean Charts" section.
1163 # The terms that are always in the WHERE clause. These implement bug
1165 sub _standard_where {
1167 # If replication lags badly between the shadow db and the main DB,
1168 # it's possible for bugs to show up in searches before their group
1169 # controls are properly set. To prevent this, when initially creating
1170 # bugs we set their creation_ts to NULL, and don't give them a creation_ts
1171 # until their group controls are set. So if a bug has a NULL creation_ts,
1172 # it shouldn't show up in searches at all.
1173 my @where = ('bugs.creation_ts IS NOT NULL');
1175 my $security_term = 'security_map.group_id IS NULL';
1177 my $user = $self->_user;
1179 my $userid = $user->id;
1180 # This indentation makes the resulting SQL more readable.
1181 $security_term .= <<END;
1183 OR (bugs.reporter_accessible = 1 AND bugs.reporter = $userid)
1184 OR (bugs.cclist_accessible = 1 AND security_cc.who IS NOT NULL)
1185 OR bugs.assigned_to = $userid
1187 if (Bugzilla->params->{'useqacontact'}) {
1188 $security_term.= " OR bugs.qa_contact = $userid";
1190 $security_term = "($security_term)";
1193 push(@where, $security_term);
1199 my ($self, $main_clause) = @_;
1200 # The newline and this particular spacing makes the resulting
1201 # SQL a bit more readable for debugging.
1202 my $where = join("\n AND ", $self->_standard_where);
1203 my $clause_sql = $main_clause->as_string;
1204 $where .= "\n AND " . $clause_sql if $clause_sql;
1208 ################################
1209 # Internal Accessors: GROUP BY #
1210 ################################
1212 # And these are the fields that we have to do GROUP BY for in DBs
1213 # that are more strict about putting everything into GROUP BY.
1217 # Strict DBs require every element from the SELECT to be in the GROUP BY,
1218 # unless that element is being used in an aggregate function.
1220 foreach my $column ($self->_select_columns) {
1221 next if $self->_skip_group_by->{$column};
1222 my $sql = $self->COLUMNS->{$column}->{name};
1223 push(@extra_group_by, $sql);
1226 # And all items from ORDER BY must be in the GROUP BY. The above loop
1227 # doesn't catch items that were put into the ORDER BY from SPECIAL_ORDER.
1228 foreach my $column ($self->_input_order_columns) {
1229 my $special_order = $self->_special_order->{$column}->{order};
1230 next if !$special_order;
1231 push(@extra_group_by, @$special_order);
1234 @extra_group_by = uniq @extra_group_by;
1236 # bug_id is the only field we actually group by.
1237 return ('bugs.bug_id', join(',', @extra_group_by));
1240 # A helper for _sql_group_by.
1241 sub _skip_group_by {
1243 return $self->{skip_group_by} if $self->{skip_group_by};
1244 my @skip_list = GROUP_BY_SKIP;
1245 push(@skip_list, keys %{ $self->_multi_select_fields });
1246 my %skip_hash = map { $_ => 1 } @skip_list;
1247 $self->{skip_group_by} = \%skip_hash;
1248 return $self->{skip_group_by};
1251 ##############################################
1252 # Internal Accessors: Special Params Parsing #
1253 ##############################################
1255 # Backwards compatibility for old field names.
1256 sub _convert_old_params {
1258 my $params = $self->_params;
1260 # bugidtype has different values in modern Search.pm.
1261 if (defined $params->{'bugidtype'}) {
1262 my $value = $params->{'bugidtype'};
1263 $params->{'bugidtype'} = $value eq 'exclude' ? 'nowords' : 'anyexact';
1266 foreach my $old_name (keys %{ FIELD_MAP() }) {
1267 if (defined $params->{$old_name}) {
1268 my $new_name = FIELD_MAP->{$old_name};
1269 $params->{$new_name} = delete $params->{$old_name};
1274 # This parses all the standard search parameters except for the boolean
1276 sub _special_charts {
1278 $self->_convert_old_params();
1279 $self->_special_parse_bug_status();
1280 $self->_special_parse_resolution();
1281 my $clause = new Bugzilla::Search::Clause();
1282 $clause->add( $self->_parse_basic_fields() );
1283 $clause->add( $self->_special_parse_email() );
1284 $clause->add( $self->_special_parse_chfield() );
1285 $clause->add( $self->_special_parse_deadline() );
1289 sub _parse_basic_fields {
1291 my $params = $self->_params;
1292 my $chart_fields = $self->_chart_fields;
1294 my $clause = new Bugzilla::Search::Clause();
1295 foreach my $field_name (keys %$chart_fields) {
1296 # CGI params shouldn't have periods in them, so we only accept
1297 # period-separated fields with underscores where the periods go.
1298 my $param_name = $field_name;
1299 $param_name =~ s/\./_/g;
1300 my @values = $self->_param_array($param_name);
1302 my $default_op = $param_name eq 'content' ? 'matches' : 'anyexact';
1303 my $operator = $params->{"${param_name}_type"} || $default_op;
1304 # Fields that are displayed as multi-selects are passed as arrays,
1305 # so that they can properly search values that contain commas.
1306 # However, other fields are sent as strings, so that they are properly
1307 # split on commas if required.
1308 my $field = $chart_fields->{$field_name};
1310 if ($field->is_select or $field->name eq 'version'
1311 or $field->name eq 'target_milestone')
1313 $pass_value = \@values;
1316 $pass_value = join(',', @values);
1318 $clause->add($field_name, $operator, $pass_value);
1323 sub _special_parse_bug_status {
1325 my $params = $self->_params;
1326 return if !defined $params->{'bug_status'};
1327 # We want to allow the bug_status_type parameter to work normally,
1328 # meaning that this special code should only be activated if we are
1329 # doing the normal "anyexact" search on bug_status.
1330 return if (defined $params->{'bug_status_type'}
1331 and $params->{'bug_status_type'} ne 'anyexact');
1333 my @bug_status = $self->_param_array('bug_status');
1334 # Also include inactive bug statuses, as you can query them.
1335 my $legal_statuses = $self->_chart_fields->{'bug_status'}->legal_values;
1337 # If the status contains __open__ or __closed__, translate those
1338 # into their equivalent lists of open and closed statuses.
1339 if (grep { $_ eq '__open__' } @bug_status) {
1340 my @open = grep { $_->is_open } @$legal_statuses;
1341 @open = map { $_->name } @open;
1342 push(@bug_status, @open);
1344 if (grep { $_ eq '__closed__' } @bug_status) {
1345 my @closed = grep { not $_->is_open } @$legal_statuses;
1346 @closed = map { $_->name } @closed;
1347 push(@bug_status, @closed);
1350 @bug_status = uniq @bug_status;
1351 my $all = grep { $_ eq "__all__" } @bug_status;
1352 # This will also handle removing __open__ and __closed__ for us
1353 # (__all__ too, which is why we check for it above, first).
1354 @bug_status = _valid_values(\@bug_status, $legal_statuses);
1356 # If the user has selected every status, change to selecting none.
1357 # This is functionally equivalent, but quite a lot faster.
1358 if ($all or scalar(@bug_status) == scalar(@$legal_statuses)) {
1359 delete $params->{'bug_status'};
1362 $params->{'bug_status'} = \@bug_status;
1366 sub _special_parse_chfield {
1368 my $params = $self->_params;
1370 my $date_from = trim(lc($params->{'chfieldfrom'} || ''));
1371 my $date_to = trim(lc($params->{'chfieldto'} || ''));
1372 $date_from = '' if $date_from eq 'now';
1373 $date_to = '' if $date_to eq 'now';
1374 my @fields = $self->_param_array('chfield');
1375 my $value_to = $params->{'chfieldvalue'};
1376 $value_to = '' if !defined $value_to;
1378 @fields = map { $_ eq '[Bug creation]' ? 'creation_ts' : $_ } @fields;
1380 my $clause = new Bugzilla::Search::Clause();
1382 # It is always safe and useful to push delta_ts into the charts
1383 # if there is a "from" date specified. It doesn't conflict with
1384 # searching [Bug creation], because a bug's delta_ts is set to
1385 # its creation_ts when it is created. So this just gives the
1386 # database an additional index to possibly choose, on a table that
1387 # is smaller than bugs_activity.
1388 if ($date_from ne '') {
1389 $clause->add('delta_ts', 'greaterthaneq', $date_from);
1391 # It's not normally safe to do it for "to" dates, though--"chfieldto" means
1392 # "a field that changed before this date", and delta_ts could be either
1393 # later or earlier than that, if we're searching for the time that a field
1394 # changed. However, chfieldto all by itself, without any chfieldvalue or
1395 # chfield, means "just search delta_ts", and so we still want that to
1397 if ($date_to ne '' and !@fields and $value_to eq '') {
1398 $clause->add('delta_ts', 'lessthaneq', $date_to);
1401 # Basically, we construct the chart like:
1403 # (added_for_field1 = value OR added_for_field2 = value)
1404 # AND (date_field1_changed >= date_from OR date_field2_changed >= date_from)
1405 # AND (date_field1_changed <= date_to OR date_field2_changed <= date_to)
1407 # Theoretically, all we *really* would need to do is look for the field id
1408 # in the bugs_activity table, because we've already limited the search
1409 # by delta_ts above, but there's no chart to do that, so we check the
1410 # change date of the fields.
1412 if ($value_to ne '') {
1413 my $value_clause = new Bugzilla::Search::Clause('OR');
1414 foreach my $field (@fields) {
1415 $value_clause->add($field, 'changedto', $value_to);
1417 $clause->add($value_clause);
1420 if ($date_from ne '') {
1421 my $from_clause = new Bugzilla::Search::Clause('OR');
1422 foreach my $field (@fields) {
1423 $from_clause->add($field, 'changedafter', $date_from);
1425 $clause->add($from_clause);
1427 if ($date_to ne '') {
1428 # chfieldto is supposed to be a relative date or a date of the form
1429 # YYYY-MM-DD, i.e. without the time appended to it. We append the
1430 # time ourselves so that the end date is correctly taken into account.
1431 $date_to .= ' 23:59:59' if $date_to =~ /^\d{4}-\d{1,2}-\d{1,2}$/;
1433 my $to_clause = new Bugzilla::Search::Clause('OR');
1434 foreach my $field (@fields) {
1435 $to_clause->add($field, 'changedbefore', $date_to);
1437 $clause->add($to_clause);
1443 sub _special_parse_deadline {
1445 return if !$self->_user->is_timetracker;
1446 my $params = $self->_params;
1448 my $clause = new Bugzilla::Search::Clause();
1449 if (my $from = $params->{'deadlinefrom'}) {
1450 $clause->add('deadline', 'greaterthaneq', $from);
1452 if (my $to = $params->{'deadlineto'}) {
1453 $clause->add('deadline', 'lessthaneq', $to);
1459 sub _special_parse_email {
1461 my $params = $self->_params;
1463 my @email_params = grep { $_ =~ /^email\d+$/ } keys %$params;
1465 my $clause = new Bugzilla::Search::Clause();
1466 foreach my $param (@email_params) {
1469 my $email = trim($params->{"email$id"});
1471 my $type = $params->{"emailtype$id"} || 'anyexact';
1472 $type = "anyexact" if $type eq "exact";
1474 my $or_clause = new Bugzilla::Search::Clause('OR');
1475 foreach my $field (qw(assigned_to reporter cc qa_contact)) {
1476 if ($params->{"email$field$id"}) {
1477 $or_clause->add($field, $type, $email);
1480 if ($params->{"emaillongdesc$id"}) {
1481 $or_clause->add("commenter", $type, $email);
1484 $clause->add($or_clause);
1490 sub _special_parse_resolution {
1492 my $params = $self->_params;
1493 return if !defined $params->{'resolution'};
1495 my @resolution = $self->_param_array('resolution');
1496 my $legal_resolutions = $self->_chart_fields->{resolution}->legal_values;
1497 @resolution = _valid_values(\@resolution, $legal_resolutions, '---');
1498 if (scalar(@resolution) == scalar(@$legal_resolutions)) {
1499 delete $params->{'resolution'};
1504 my ($input, $valid, $extra_value) = @_;
1506 foreach my $item (@$input) {
1507 $item = trim($item);
1508 if (defined $extra_value and $item eq $extra_value) {
1509 push(@result, $item);
1511 elsif (grep { $_->name eq $item } @$valid) {
1512 push(@result, $item);
1518 ######################################
1519 # Internal Accessors: Boolean Charts #
1520 ######################################
1522 sub _charts_to_conditions {
1525 my $clause = $self->_charts;
1527 $clause->walk_conditions(sub {
1528 my ($condition) = @_;
1529 return if !$condition->translated;
1530 push(@joins, @{ $condition->translated->{joins} });
1532 return (\@joins, $clause);
1538 my $clause = $self->_params_to_data_structure();
1540 $clause->walk_conditions(sub { $self->_handle_chart($chart_id++, @_) });
1544 sub _params_to_data_structure {
1547 # First we get the "special" charts, representing all the normal
1548 # field son the search page. This may modify _params, so it needs to
1550 my $clause = $self->_special_charts;
1552 # Then we process the old Boolean Charts input format.
1553 $clause->add( $self->_boolean_charts );
1555 # And then process the modern "custom search" format.
1556 $clause->add( $self->_custom_search );
1561 sub _boolean_charts {
1564 my $params = $self->_params;
1565 my @param_list = keys %$params;
1567 my @all_field_params = grep { /^field-?\d+/ } @param_list;
1568 my @chart_ids = map { /^field(-?\d+)/; $1 } @all_field_params;
1569 @chart_ids = sort { $a <=> $b } uniq @chart_ids;
1571 my $clause = new Bugzilla::Search::Clause();
1572 foreach my $chart_id (@chart_ids) {
1573 my @all_and = grep { /^field$chart_id-\d+/ } @param_list;
1574 my @and_ids = map { /^field$chart_id-(\d+)/; $1 } @all_and;
1575 @and_ids = sort { $a <=> $b } uniq @and_ids;
1577 my $and_clause = new Bugzilla::Search::Clause();
1578 foreach my $and_id (@and_ids) {
1579 my @all_or = grep { /^field$chart_id-$and_id-\d+/ } @param_list;
1580 my @or_ids = map { /^field$chart_id-$and_id-(\d+)/; $1 } @all_or;
1581 @or_ids = sort { $a <=> $b } uniq @or_ids;
1583 my $or_clause = new Bugzilla::Search::Clause('OR');
1584 foreach my $or_id (@or_ids) {
1585 my $identifier = "$chart_id-$and_id-$or_id";
1586 my $field = $params->{"field$identifier"};
1587 my $operator = $params->{"type$identifier"};
1588 my $value = $params->{"value$identifier"};
1589 $or_clause->add($field, $operator, $value);
1591 $and_clause->add($or_clause);
1592 $and_clause->negate(1) if $params->{"negate$chart_id"};
1594 $clause->add($and_clause);
1600 sub _custom_search {
1602 my $params = $self->_params;
1604 my $current_clause = new Bugzilla::Search::Clause($params->{j_top});
1606 foreach my $id ($self->_field_ids) {
1607 my $field = $params->{"f$id"};
1608 if ($field eq 'OP') {
1609 my $joiner = $params->{"j$id"};
1610 my $new_clause = new Bugzilla::Search::Clause($joiner);
1611 $new_clause->negate($params->{"n$id"});
1612 $current_clause->add($new_clause);
1613 push(@clause_stack, $current_clause);
1614 $current_clause = $new_clause;
1617 if ($field eq 'CP') {
1618 $current_clause = pop @clause_stack;
1619 ThrowCodeError('search_cp_without_op', { id => $id })
1620 if !$current_clause;
1624 my $operator = $params->{"o$id"};
1625 my $value = $params->{"v$id"};
1626 my $condition = condition($field, $operator, $value);
1627 $condition->negate($params->{"n$id"});
1628 $current_clause->add($condition);
1631 # We allow people to specify more OPs than CPs, so at the end of the
1632 # loop our top clause may be still in the stack instead of being
1634 return $clause_stack[0] || $current_clause;
1639 my $params = $self->_params;
1640 my @param_list = keys %$params;
1642 my @field_params = grep { /^f\d+$/ } @param_list;
1643 my @field_ids = map { /(\d+)/; $1 } @field_params;
1644 @field_ids = sort { $a <=> $b } @field_ids;
1649 my ($self, $chart_id, $condition) = @_;
1650 my $dbh = Bugzilla->dbh;
1651 my $params = $self->_params;
1652 my ($field, $operator, $value) = $condition->fov;
1654 $field = FIELD_MAP->{$field} || $field;
1656 return if (!defined $field or !defined $operator or !defined $value);
1659 if (ref $value eq 'ARRAY') {
1660 # Trim input and ignore blank values.
1661 @$value = map { trim($_) } @$value;
1662 @$value = grep { defined $_ and $_ ne '' } @$value;
1664 $string_value = join(',', @$value);
1667 return if $value eq '';
1668 $string_value = $value;
1671 $self->_chart_fields->{$field}
1672 or ThrowCodeError("invalid_field_name", { field => $field });
1673 trick_taint($field);
1675 # This is the field as you'd reference it in a SQL statement.
1676 my $full_field = $field =~ /\./ ? $field : "bugs.$field";
1678 # "value" and "quoted" are for search functions that always operate
1679 # on a scalar string and never care if they were passed multiple
1680 # parameters. If the user does pass multiple parameters, they will
1681 # become a space-separated string for those search functions.
1683 # all_values is for search functions that do operate
1684 # on multiple values, like anyexact.
1687 chart_id => $chart_id,
1688 sequence => $chart_id,
1690 full_field => $full_field,
1691 operator => $operator,
1692 value => $string_value,
1693 all_values => $value,
1695 condition => $condition,
1697 $search_args{quoted} = $self->_quote_unless_numeric(\%search_args);
1698 # This should add a "term" selement to %search_args.
1699 $self->do_search_function(\%search_args);
1701 # If term is left empty, then this means the criteria
1702 # has no effect and can be ignored.
1703 return unless $search_args{term};
1705 # All the things here that don't get pulled out of
1706 # %search_args are their original values before
1707 # do_search_function modified them.
1708 $self->search_description({
1709 field => $field, type => $operator,
1710 value => $string_value, term => $search_args{term},
1713 $condition->translated(\%search_args);
1716 ##################################
1717 # do_search_function And Helpers #
1718 ##################################
1720 # This takes information about the current boolean chart and translates
1721 # it into SQL, using the constants at the top of this file.
1722 sub do_search_function {
1723 my ($self, $args) = @_;
1724 my ($field, $operator) = @$args{qw(field operator)};
1726 if (my $parse_func = SPECIAL_PARSING->{$field}) {
1727 $self->$parse_func($args);
1728 # Some parsing functions set $term, though most do not.
1729 # For the ones that set $term, we don't need to do any further
1731 return if $args->{term};
1734 my $operator_field_override = $self->_get_operator_field_override();
1735 my $override = $operator_field_override->{$field};
1736 # Attachment fields get special handling, if they don't have a specific
1737 # individual override.
1738 if (!$override and $field =~ /^attachments\./) {
1739 $override = $operator_field_override->{attachments};
1741 # If there's still no override, check for an override on the field's type.
1743 my $field_obj = $self->_chart_fields->{$field};
1744 $override = $operator_field_override->{$field_obj->type};
1748 my $search_func = $self->_pick_override_function($override, $operator);
1749 $self->$search_func($args) if $search_func;
1752 # Some search functions set $term, and some don't. For the ones that
1753 # don't (or for fields that don't have overrides) we now call the
1754 # direct operator function from OPERATORS.
1755 if (!defined $args->{term}) {
1756 $self->_do_operator_function($args);
1759 if (!defined $args->{term}) {
1760 # This field and this type don't work together. Generally,
1761 # this should never be reached, because it should be handled
1762 # explicitly by OPERATOR_FIELD_OVERRIDE.
1763 ThrowUserError("search_field_operator_invalid",
1764 { field => $field, operator => $operator });
1768 # A helper for various search functions that need to run operator
1769 # functions directly.
1770 sub _do_operator_function {
1771 my ($self, $func_args) = @_;
1772 my $operator = $func_args->{operator};
1773 my $operator_func = OPERATORS->{$operator}
1774 || ThrowCodeError("search_field_operator_unsupported",
1775 { operator => $operator });
1776 $self->$operator_func($func_args);
1779 sub _reverse_operator {
1780 my ($self, $operator) = @_;
1781 my $reverse = OPERATOR_REVERSE->{$operator};
1782 return $reverse if $reverse;
1783 if ($operator =~ s/^not//) {
1786 return "not$operator";
1789 sub _pick_override_function {
1790 my ($self, $override, $operator) = @_;
1791 my $search_func = $override->{$operator};
1793 if (!$search_func) {
1794 # If we don't find an override for one specific operator,
1795 # then there are some special override types:
1796 # _non_changed: For any operator that doesn't have the word
1798 # _default: Overrides all operators that aren't explicitly specified.
1799 if ($override->{_non_changed} and $operator !~ /changed/) {
1800 $search_func = $override->{_non_changed};
1802 elsif ($override->{_default}) {
1803 $search_func = $override->{_default};
1807 return $search_func;
1810 sub _get_operator_field_override {
1812 my $cache = Bugzilla->request_cache;
1814 return $cache->{operator_field_override}
1815 if defined $cache->{operator_field_override};
1817 my %operator_field_override = %{ OPERATOR_FIELD_OVERRIDE() };
1818 Bugzilla::Hook::process('search_operator_field_override',
1820 operators => \%operator_field_override });
1822 $cache->{operator_field_override} = \%operator_field_override;
1823 return $cache->{operator_field_override};
1826 sub _get_column_joins {
1828 my $cache = Bugzilla->request_cache;
1830 return $cache->{column_joins} if defined $cache->{column_joins};
1832 my %column_joins = %{ COLUMN_JOINS() };
1833 Bugzilla::Hook::process('buglist_column_joins',
1834 { column_joins => \%column_joins });
1836 $cache->{column_joins} = \%column_joins;
1837 return $cache->{column_joins};
1840 ###########################
1841 # Search Function Helpers #
1842 ###########################
1844 # When we're doing a numeric search against a numeric column, we want to
1845 # just put a number into the SQL instead of a string. On most DBs, this
1846 # is just a performance optimization, but on SQLite it actually changes
1847 # the behavior of some searches.
1848 sub _quote_unless_numeric {
1849 my ($self, $args, $value) = @_;
1850 if (!defined $value) {
1851 $value = $args->{value};
1853 my ($field, $operator) = @$args{qw(field operator)};
1855 my $numeric_operator = !grep { $_ eq $operator } NON_NUMERIC_OPERATORS;
1856 my $numeric_field = $self->_chart_fields->{$field}->is_numeric;
1857 my $numeric_value = ($value =~ NUMBER_REGEX) ? 1 : 0;
1858 my $is_numeric = $numeric_operator && $numeric_field && $numeric_value;
1860 my $quoted = $value;
1861 trick_taint($quoted);
1864 return Bugzilla->dbh->quote($value);
1867 sub build_subselect {
1868 my ($outer, $inner, $table, $cond, $negate) = @_;
1869 # Execute subselects immediately to avoid dependent subqueries, which are
1870 # large performance hits on MySql
1871 my $q = "SELECT DISTINCT $inner FROM $table WHERE $cond";
1872 my $dbh = Bugzilla->dbh;
1873 my $list = $dbh->selectcol_arrayref($q);
1874 return $negate ? "1=1" : "1=2" unless @$list;
1875 return $dbh->sql_in($outer, $list, $negate);
1878 # Used by anyexact to get the list of input values. This allows us to
1879 # support values with commas inside of them in the standard charts, and
1880 # still accept string values for the boolean charts (and split them on
1883 my ($self, $args, $split_on) = @_;
1884 $split_on ||= qr/[\s,]+/;
1885 my $dbh = Bugzilla->dbh;
1886 my $all_values = $args->{all_values};
1889 if (ref $all_values eq 'ARRAY') {
1890 @array = @$all_values;
1893 @array = split($split_on, $all_values);
1894 @array = map { trim($_) } @array;
1895 @array = grep { defined $_ and $_ ne '' } @array;
1898 if ($args->{field} eq 'resolution') {
1899 @array = map { $_ eq '---' ? '' : $_ } @array;
1905 # Support for "any/all/nowordssubstr" comparison type ("words as substrings")
1906 sub _substring_terms {
1907 my ($self, $args) = @_;
1908 my $dbh = Bugzilla->dbh;
1910 # We don't have to (or want to) use _all_values, because we'd just
1911 # split each term on spaces and commas anyway.
1912 my @words = split(/[\s,]+/, $args->{value});
1913 @words = grep { defined $_ and $_ ne '' } @words;
1914 @words = map { $dbh->quote($_) } @words;
1915 my @terms = map { $dbh->sql_iposition($_, $args->{full_field}) . " > 0" }
1921 my ($self, $args) = @_;
1922 my $dbh = Bugzilla->dbh;
1924 my @values = split(/[\s,]+/, $args->{value});
1925 @values = grep { defined $_ and $_ ne '' } @values;
1926 my @substring_terms = $self->_substring_terms($args);
1929 my $start = $dbh->WORD_START;
1930 my $end = $dbh->WORD_END;
1931 foreach my $word (@values) {
1932 my $regex = $start . quotemeta($word) . $end;
1933 my $quoted = $dbh->quote($regex);
1934 # We don't have to check the regexp, because we escaped it, so we're
1936 my $regex_term = $dbh->sql_regexp($args->{full_field}, $quoted,
1938 # Regular expressions are slow--substring searches are faster.
1939 # If we're searching for a word, we're also certain that the
1940 # substring will appear in the value. So we limit first by
1941 # substring and then by a regex that will match just words.
1942 my $substring_term = shift @substring_terms;
1943 push(@terms, "$substring_term AND $regex_term");
1949 #####################################
1950 # "Special Parsing" Functions: Date #
1951 #####################################
1953 sub _timestamp_translate {
1954 my ($self, $args) = @_;
1955 my $value = $args->{value};
1956 my $dbh = Bugzilla->dbh;
1958 return if $value !~ /^(?:[\+\-]?\d+[hdwmy]s?|now)$/i;
1960 # By default, the time is appended to the date, which we don't want
1962 $value = SqlifyDate($value);
1963 if ($args->{field} eq 'deadline') {
1964 ($value) = split(/\s/, $value);
1966 $args->{value} = $value;
1967 $args->{quoted} = $dbh->quote($value);
1972 my $fmt = "%Y-%m-%d %H:%M:%S";
1973 $str = "" if (!defined $str || lc($str) eq 'now');
1975 my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime(time());
1976 return sprintf("%4d-%02d-%02d 00:00:00", $year+1900, $month+1, $mday);
1979 if ($str =~ /^(-|\+)?(\d+)([hdwmy])(s?)$/i) { # relative date
1980 my ($sign, $amount, $unit, $startof, $date) = ($1, $2, lc $3, lc $4, time);
1981 my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date);
1982 if ($sign && $sign eq '+') { $amount = -$amount; }
1983 $startof = 1 if $amount == 0;
1984 if ($unit eq 'w') { # convert weeks to days
1985 $amount = 7*$amount;
1986 $amount += $wday if $startof;
1991 $fmt = "%Y-%m-%d 00:00:00";
1992 $date -= $sec + 60*$min + 3600*$hour;
1994 $date -= 24*3600*$amount;
1995 return time2str($fmt, $date);
1997 elsif ($unit eq 'y') {
1999 return sprintf("%4d-01-01 00:00:00", $year+1900-$amount);
2002 return sprintf("%4d-%02d-%02d %02d:%02d:%02d",
2003 $year+1900-$amount, $month+1, $mday, $hour, $min, $sec);
2006 elsif ($unit eq 'm') {
2008 while ($month<0) { $year--; $month += 12; }
2010 return sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1);
2013 return sprintf("%4d-%02d-%02d %02d:%02d:%02d",
2014 $year+1900, $month+1, $mday, $hour, $min, $sec);
2017 elsif ($unit eq 'h') {
2018 # Special case for 'beginning of an hour'
2020 $fmt = "%Y-%m-%d %H:00:00";
2022 $date -= 3600*$amount;
2023 return time2str($fmt, $date);
2025 return undef; # should not happen due to regexp at top
2027 my $date = str2time($str);
2028 if (!defined($date)) {
2029 ThrowUserError("illegal_date", { date => $str });
2031 return time2str($fmt, $date);
2034 ######################################
2035 # "Special Parsing" Functions: Users #
2036 ######################################
2039 my ($noun, $user) = (@_);
2040 if ($noun eq "%user%") {
2044 ThrowUserError('login_required_for_pronoun');
2047 if ($noun eq "%reporter%") {
2048 return "bugs.reporter";
2050 if ($noun eq "%assignee%") {
2051 return "bugs.assigned_to";
2053 if ($noun eq "%qacontact%") {
2054 return "COALESCE(bugs.qa_contact,0)";
2059 sub _contact_pronoun {
2060 my ($self, $args) = @_;
2061 my $value = $args->{value};
2062 my $user = $self->_user;
2064 if ($value =~ /^\%group\.[^%]+%$/) {
2065 $self->_contact_exact_group($args);
2067 elsif ($value =~ /^(%\w+%)$/) {
2068 $args->{value} = pronoun($1, $user);
2069 $args->{quoted} = $args->{value};
2070 $args->{value_is_id} = 1;
2074 sub _contact_exact_group {
2075 my ($self, $args) = @_;
2076 my ($value, $operator, $field, $chart_id, $joins) =
2077 @$args{qw(value operator field chart_id joins)};
2078 my $dbh = Bugzilla->dbh;
2079 my $user = $self->_user;
2081 # We already know $value will match this regexp, else we wouldn't be here.
2082 $value =~ /\%group\.([^%]+)%/;
2083 my $group_name = $1;
2084 my $group = Bugzilla::Group->check({ name => $group_name, _error => 'invalid_group_name' });
2085 # Pass $group_name instead of $group->name to the error message
2086 # to not leak the existence of the group.
2087 $user->in_group($group)
2088 || ThrowUserError('invalid_group_name', { name => $group_name });
2089 # Now that we know the user belongs to this group, it's safe
2090 # to disclose more information.
2091 $group->check_members_are_visible();
2093 my $group_ids = Bugzilla::Group->flatten_group_membership($group->id);
2094 my $table = "user_group_map_$chart_id";
2096 table => 'user_group_map',
2100 extra => [$dbh->sql_in("$table.group_id", $group_ids),
2101 "$table.isbless = 0"],
2103 push(@$joins, $join);
2104 if ($operator =~ /^not/) {
2105 $args->{term} = "$table.group_id IS NULL";
2108 $args->{term} = "$table.group_id IS NOT NULL";
2113 my ($self, $args) = @_;
2114 my ($full_field, $value) = @$args{qw(full_field value)};
2115 my $user = $self->_user;
2117 if ($value =~ /\%group/) {
2118 return $self->_cc_exact_group($args);
2120 elsif ($value =~ /^(%\w+%)$/) {
2121 $args->{value} = pronoun($1, $user);
2122 $args->{quoted} = $args->{value};
2123 $args->{value_is_id} = 1;
2127 sub _cc_exact_group {
2128 my ($self, $args) = @_;
2129 my ($chart_id, $sequence, $joins, $operator, $value) =
2130 @$args{qw(chart_id sequence joins operator value)};
2131 my $user = $self->_user;
2132 my $dbh = Bugzilla->dbh;
2134 $value =~ m/%group\.([^%]+)%/;
2135 my $group = Bugzilla::Group->check({ name => $1, _error => 'invalid_group_name' });
2136 $group->check_members_are_visible();
2137 $user->in_group($group)
2138 || ThrowUserError('invalid_group_name', {name => $group->name});
2140 my $all_groups = Bugzilla::Group->flatten_group_membership($group->id);
2142 # This is for the email1, email2, email3 fields from query.cgi.
2143 if ($chart_id eq "") {
2144 $chart_id = "CC$$sequence";
2145 $args->{sequence}++;
2148 my $cc_table = "cc_$chart_id";
2149 push(@$joins, { table => 'cc', as => $cc_table });
2150 my $group_table = "user_group_map_$chart_id";
2152 table => 'user_group_map',
2154 from => "$cc_table.who",
2156 extra => [$dbh->sql_in("$group_table.group_id", $all_groups),
2157 "$group_table.isbless = 0"],
2159 push(@$joins, $group_join);
2161 if ($operator =~ /^not/) {
2162 $args->{term} = "$group_table.group_id IS NULL";
2165 $args->{term} = "$group_table.group_id IS NOT NULL";
2169 # XXX This should probably be merged with cc_pronoun.
2170 sub _commenter_pronoun {
2171 my ($self, $args) = @_;
2172 my $value = $args->{value};
2173 my $user = $self->_user;
2175 if ($value =~ /^(%\w+%)$/) {
2176 $args->{value} = pronoun($1, $user);
2177 $args->{quoted} = $args->{value};
2178 $args->{value_is_id} = 1;
2182 #####################################################################
2184 #####################################################################
2186 sub _invalid_combination {
2187 my ($self, $args) = @_;
2188 my ($field, $operator) = @$args{qw(field operator)};
2189 ThrowUserError('search_field_operator_invalid',
2190 { field => $field, operator => $operator });
2193 # For all the "user" fields--assigned_to, reporter, qa_contact,
2194 # cc, commenter, requestee, etc.
2195 sub _user_nonchanged {
2196 my ($self, $args) = @_;
2197 my ($field, $operator, $chart_id, $sequence, $joins) =
2198 @$args{qw(field operator chart_id sequence joins)};
2200 my $is_in_other_table;
2201 if (my $join = USER_FIELDS->{$field}->{join}) {
2202 $is_in_other_table = 1;
2203 my $as = "${field}_$chart_id";
2204 # Needed for setters.login_name and requestees.login_name.
2205 # Otherwise when we try to join "profiles" below, we'd get
2206 # something like "setters.login_name.login_name" in the "from".
2208 # This helps implement the email1, email2, etc. parameters.
2209 if ($chart_id =~ /default/) {
2210 $as .= "_$sequence";
2212 my $isprivate = USER_FIELDS->{$field}->{isprivate};
2213 my $extra = ($isprivate and !$self->_user->is_insider)
2214 ? ["$as.isprivate = 0"] : [];
2215 # We want to copy $join so as not to modify USER_FIELDS.
2216 push(@$joins, { %$join, as => $as, extra => $extra });
2217 my $search_field = USER_FIELDS->{$field}->{field};
2218 $args->{full_field} = "$as.$search_field";
2221 my $is_nullable = USER_FIELDS->{$field}->{nullable};
2222 my $null_alternate = "''";
2223 # When using a pronoun, we use the userid, and we don't have to
2224 # join the profiles table.
2225 if ($args->{value_is_id}) {
2226 $null_alternate = 0;
2229 my $as = "name_${field}_$chart_id";
2230 # For fields with periods in their name.
2233 table => 'profiles',
2235 from => $args->{full_field},
2237 join => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
2239 push(@$joins, $join);
2240 $args->{full_field} = "$as.login_name";
2243 # We COALESCE fields that can be NULL, to make "not"-style operators
2244 # continue to work properly. For example, "qa_contact is not equal to bob"
2245 # should also show bugs where the qa_contact is NULL. With COALESCE,
2248 $args->{full_field} = "COALESCE($args->{full_field}, $null_alternate)";
2251 # For fields whose values are stored in other tables, negation (NOT)
2252 # only works properly if we put the condition into the JOIN instead
2254 if ($is_in_other_table) {
2255 # Using the last join works properly whether we're searching based
2256 # on userid or login_name.
2257 my $last_join = $joins->[-1];
2259 # For negative operators, the system we're using here
2260 # only works properly if we reverse the operator and check IS NULL
2262 my $is_negative = $operator =~ /^no/ ? 1 : 0;
2264 $args->{operator} = $self->_reverse_operator($operator);
2266 $self->_do_operator_function($args);
2267 push(@{ $last_join->{extra} }, $args->{term});
2269 # For login_name searches, we only want a single join.
2270 # So we create a subselect table out of our two joins. This makes
2271 # negation (NOT) work properly for values that are in other
2273 if ($last_join->{table} eq 'profiles') {
2275 $last_join->{join} = 'INNER';
2276 my ($join_sql) = $self->_translate_join($last_join);
2277 my $first_join = $joins->[-1];
2278 my $as = $first_join->{as};
2279 my $table = $first_join->{table};
2280 my $columns = "bug_id";
2281 $columns .= ",isprivate" if @{ $first_join->{extra} };
2282 my $new_table = "SELECT DISTINCT $columns FROM $table AS $as $join_sql";
2283 $first_join->{table} = "($new_table)";
2284 # We always want to LEFT JOIN the generated table.
2285 delete $first_join->{join};
2286 # To support OR charts, we need multiple tables.
2287 my $new_as = $first_join->{as} . "_$sequence";
2288 $_ =~ s/\Q$as\E/$new_as/ foreach @{ $first_join->{extra} };
2289 $first_join->{as} = $new_as;
2290 $last_join = $first_join;
2293 # If we're joining the first table (we're using a pronoun and
2294 # searching by user id) then we need to check $other_table->{field}.
2295 my $check_field = $last_join->{as} . '.bug_id';
2297 $args->{term} = "$check_field IS NULL";
2300 $args->{term} = "$check_field IS NOT NULL";
2305 # XXX This duplicates having Commenter as a search field.
2306 sub _long_desc_changedby {
2307 my ($self, $args) = @_;
2308 my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
2310 my $table = "longdescs_$chart_id";
2311 push(@$joins, { table => 'longdescs', as => $table });
2312 my $user_id = login_to_id($value, THROW_ERROR);
2313 $args->{term} = "$table.who = $user_id";
2316 sub _long_desc_changedbefore_after {
2317 my ($self, $args) = @_;
2318 my ($chart_id, $operator, $value, $joins) =
2319 @$args{qw(chart_id operator value joins)};
2320 my $dbh = Bugzilla->dbh;
2322 my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2323 my $table = "longdescs_$chart_id";
2324 my $sql_date = $dbh->quote(SqlifyDate($value));
2326 table => 'longdescs',
2328 extra => ["$table.bug_when $sql_operator $sql_date"],
2330 push(@$joins, $join);
2331 $args->{term} = "$table.bug_when IS NOT NULL";
2333 # If the user is not part of the insiders group, they cannot see
2335 if (!$self->_user->is_insider) {
2336 $args->{term} .= " AND $table.isprivate = 0";
2340 sub _long_desc_nonchanged {
2341 my ($self, $args) = @_;
2342 my ($chart_id, $operator, $value, $joins) =
2343 @$args{qw(chart_id operator value joins)};
2344 my $dbh = Bugzilla->dbh;
2346 my $table = "longdescs_$chart_id";
2348 chart_id => $chart_id,
2349 sequence => $chart_id,
2350 field => 'longdesc',
2351 full_field => "$table.thetext",
2352 operator => $operator,
2354 all_values => $value,
2355 quoted => $dbh->quote($value),
2358 $self->_do_operator_function($join_args);
2360 # If the user is not part of the insiders group, they cannot see
2362 if (!$self->_user->is_insider) {
2363 $join_args->{term} .= " AND $table.isprivate = 0";
2367 table => 'longdescs',
2369 extra => [ $join_args->{term} ],
2371 push(@$joins, $join);
2373 $args->{term} = "$table.comment_id IS NOT NULL";
2376 sub _content_matches {
2377 my ($self, $args) = @_;
2378 my ($chart_id, $joins, $fields, $operator, $value) =
2379 @$args{qw(chart_id joins fields operator value)};
2380 my $dbh = Bugzilla->dbh;
2382 # "content" is an alias for columns containing text for which we
2383 # can search a full-text index and retrieve results by relevance,
2384 # currently just bug comments (and summaries to some degree).
2385 # There's only one way to search a full-text index, so we only
2386 # accept the "matches" operator, which is specific to full-text
2389 # Add the fulltext table to the query so we can search on it.
2390 my $table = "bugs_fulltext_$chart_id";
2391 my $comments_col = "comments";
2392 $comments_col = "comments_noprivate" unless $self->_user->is_insider;
2393 push(@$joins, { table => 'bugs_fulltext', as => $table });
2395 # Create search terms to add to the SELECT and WHERE clauses.
2396 my ($term1, $rterm1) =
2397 $dbh->sql_fulltext_search("$table.$comments_col", $value);
2398 my ($term2, $rterm2) =
2399 $dbh->sql_fulltext_search("$table.short_desc", $value);
2400 $rterm1 = $term1 if !$rterm1;
2401 $rterm2 = $term2 if !$rterm2;
2403 # The term to use in the WHERE clause.
2404 my $term = "$term1 OR $term2";
2405 if ($operator =~ /not/i) {
2406 $term = "NOT($term)";
2408 $args->{term} = $term;
2410 # In order to sort by relevance (in case the user requests it),
2411 # we SELECT the relevance value so we can add it to the ORDER BY
2412 # clause. Every time a new fulltext chart isadded, this adds more
2413 # terms to the relevance sql.
2415 # We build the relevance SQL by modifying the COLUMNS list directly,
2416 # which is kind of a hack but works.
2417 my $current = $self->COLUMNS->{'relevance'}->{name};
2418 $current = $current ? "$current + " : '';
2419 # For NOT searches, we just add 0 to the relevance.
2420 my $select_term = $operator =~ /not/ ? 0 : "($current$rterm1 + $rterm2)";
2421 $self->COLUMNS->{'relevance'}->{name} = $select_term;
2424 sub _long_descs_count {
2425 my ($self, $args) = @_;
2426 my ($chart_id, $joins) = @$args{qw(chart_id joins)};
2427 my $table = "longdescs_count_$chart_id";
2428 my $extra = $self->_user->is_insider ? "" : "WHERE isprivate = 0";
2430 table => "(SELECT bug_id, COUNT(*) AS num"
2431 . " FROM longdescs $extra GROUP BY bug_id)",
2434 push(@$joins, $join);
2435 $args->{full_field} = "${table}.num";
2438 sub _work_time_changedby {
2439 my ($self, $args) = @_;
2440 my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
2442 my $table = "longdescs_$chart_id";
2443 push(@$joins, { table => 'longdescs', as => $table });
2444 my $user_id = login_to_id($value, THROW_ERROR);
2445 $args->{term} = "$table.who = $user_id AND $table.work_time != 0";
2448 sub _work_time_changedbefore_after {
2449 my ($self, $args) = @_;
2450 my ($chart_id, $operator, $value, $joins) =
2451 @$args{qw(chart_id operator value joins)};
2452 my $dbh = Bugzilla->dbh;
2454 my $table = "longdescs_$chart_id";
2455 my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2456 my $sql_date = $dbh->quote(SqlifyDate($value));
2458 table => 'longdescs',
2460 extra => ["$table.work_time != 0",
2461 "$table.bug_when $sql_operator $sql_date"],
2463 push(@$joins, $join);
2465 $args->{term} = "$table.bug_when IS NOT NULL";
2469 my ($self, $args) = @_;
2470 $self->_add_extra_column('actual_time');
2471 $args->{full_field} = $self->COLUMNS->{actual_time}->{name};
2474 sub _percentage_complete {
2475 my ($self, $args) = @_;
2477 $args->{full_field} = $self->COLUMNS->{percentage_complete}->{name};
2479 # We need actual_time in _select_columns, otherwise we can't use
2480 # it in the expression for searching percentage_complete.
2481 $self->_add_extra_column('actual_time');
2485 my ($self, $args) = @_;
2486 my $dbh = Bugzilla->dbh;
2488 $args->{full_field} = "(" . $dbh->sql_to_days('NOW()') . " - " .
2489 $dbh->sql_to_days('bugs.delta_ts') . ")";
2492 sub _component_nonchanged {
2493 my ($self, $args) = @_;
2495 $args->{full_field} = "components.name";
2496 $self->_do_operator_function($args);
2497 my $term = $args->{term};
2498 $args->{term} = build_subselect("bugs.component_id",
2499 "components.id", "components", $args->{term});
2502 sub _product_nonchanged {
2503 my ($self, $args) = @_;
2505 # Generate the restriction condition
2506 $args->{full_field} = "products.name";
2507 $self->_do_operator_function($args);
2508 my $term = $args->{term};
2509 $args->{term} = build_subselect("bugs.product_id",
2510 "products.id", "products", $term);
2513 sub _classification_nonchanged {
2514 my ($self, $args) = @_;
2515 my $joins = $args->{joins};
2517 # This joins the right tables for us.
2518 $self->_add_extra_column('product');
2520 # Generate the restriction condition
2521 $args->{full_field} = "classifications.name";
2522 $self->_do_operator_function($args);
2523 my $term = $args->{term};
2524 $args->{term} = build_subselect("map_product.classification_id",
2525 "classifications.id", "classifications", $term);
2529 my ($self, $args) = @_;
2530 my $field = $args->{full_field};
2531 $args->{full_field} = "COALESCE($field, '')";
2535 my ($self, $args) = @_;
2536 my $field = $args->{full_field};
2537 $args->{full_field} = "COALESCE($field, 0)";
2540 sub _nullable_datetime {
2541 my ($self, $args) = @_;
2542 my $field = $args->{full_field};
2543 my $empty = Bugzilla->dbh->quote(EMPTY_DATETIME);
2544 $args->{full_field} = "COALESCE($field, $empty)";
2548 my ($self, $args) = @_;
2549 my $field = $args->{full_field};
2550 # This makes "equals" searches work on all DBs (even on MySQL, which
2551 # has a bug: http://bugs.mysql.com/bug.php?id=60324).
2552 $args->{full_field} = Bugzilla->dbh->sql_date_format($field, '%Y-%m-%d');
2553 $self->_nullable_datetime($args);
2556 sub _owner_idle_time_greater_less {
2557 my ($self, $args) = @_;
2558 my ($chart_id, $joins, $value, $operator) =
2559 @$args{qw(chart_id joins value operator)};
2560 my $dbh = Bugzilla->dbh;
2562 my $table = "idle_$chart_id";
2563 my $quoted = $dbh->quote(SqlifyDate($value));
2565 my $ld_table = "comment_$table";
2566 my $act_table = "activity_$table";
2567 my $comments_join = {
2568 table => 'longdescs',
2570 from => 'assigned_to',
2572 extra => ["$ld_table.bug_when > $quoted"],
2574 my $activity_join = {
2575 table => 'bugs_activity',
2577 from => 'assigned_to',
2579 extra => ["$act_table.bug_when > $quoted"]
2582 push(@$joins, $comments_join, $activity_join);
2584 if ($operator =~ /greater/) {
2586 "$ld_table.who IS NULL AND $act_table.who IS NULL";
2589 "$ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL";
2593 sub _multiselect_negative {
2594 my ($self, $args) = @_;
2595 my ($field, $operator) = @$args{qw(field operator)};
2597 $args->{operator} = $self->_reverse_operator($operator);
2598 $args->{term} = $self->_multiselect_term($args, 1);
2601 sub _multiselect_multiple {
2602 my ($self, $args) = @_;
2603 my ($chart_id, $field, $operator, $value)
2604 = @$args{qw(chart_id field operator value)};
2605 my $dbh = Bugzilla->dbh;
2607 # We want things like "cf_multi_select=two+words" to still be
2608 # considered a search for two separate words, unless we're using
2609 # anyexact. (_all_values would consider that to be one "word" with a
2610 # space in it, because it's not in the Boolean Charts).
2611 my @words = $operator eq 'anyexact' ? $self->_all_values($args)
2612 : split(/[\s,]+/, $value);
2615 foreach my $word (@words) {
2616 next if $word eq '';
2617 $args->{value} = $word;
2618 $args->{quoted} = $dbh->quote($word);
2619 push(@terms, $self->_multiselect_term($args));
2622 # The spacing in the joins helps make the resulting SQL more readable.
2623 if ($operator =~ /^any/) {
2624 $args->{term} = join("\n OR ", @terms);
2627 $args->{term} = join("\n AND ", @terms);
2631 sub _flagtypes_nonchanged {
2632 my ($self, $args) = @_;
2633 my ($chart_id, $operator, $value, $joins, $condition) =
2634 @$args{qw(chart_id operator value joins condition)};
2635 my $dbh = Bugzilla->dbh;
2637 # For 'not' operators, we need to negate the whole term.
2638 # If you search for "Flags" (does not contain) "approval+" we actually want
2639 # to return *bugs* that don't contain an approval+ flag. Without rewriting
2640 # the negation we'll search for *flags* which don't contain approval+.
2641 if ($operator =~ s/^not//) {
2642 $args->{operator} = $operator;
2643 $condition->operator($operator);
2644 $condition->negate(1);
2647 my $subselect_args = {
2648 chart_id => $chart_id,
2649 sequence => $chart_id,
2650 field => 'flagtypes.name',
2651 full_field => $dbh->sql_string_concat("flagtypes_$chart_id.name", "flags_$chart_id.status"),
2652 operator => $operator,
2654 all_values => $value,
2655 quoted => $dbh->quote($value),
2658 $self->_do_operator_function($subselect_args);
2659 my $subselect_term = $subselect_args->{term};
2661 # don't call build_subselect as this must run as a true sub-select
2662 $args->{term} = "EXISTS (
2664 FROM bugs bugs_$chart_id
2665 LEFT JOIN attachments AS attachments_$chart_id
2666 ON bugs_$chart_id.bug_id = attachments_$chart_id.bug_id
2667 LEFT JOIN flags AS flags_$chart_id
2668 ON bugs_$chart_id.bug_id = flags_$chart_id.bug_id
2669 AND (flags_$chart_id.attach_id = attachments_$chart_id.attach_id
2670 OR flags_$chart_id.attach_id IS NULL)
2671 LEFT JOIN flagtypes AS flagtypes_$chart_id
2672 ON flags_$chart_id.type_id = flagtypes_$chart_id.id
2673 WHERE bugs_$chart_id.bug_id = bugs.bug_id
2678 sub _multiselect_nonchanged {
2679 my ($self, $args) = @_;
2680 my ($chart_id, $joins, $field, $operator) =
2681 @$args{qw(chart_id joins field operator)};
2682 $args->{term} = $self->_multiselect_term($args)
2685 sub _multiselect_table {
2686 my ($self, $args) = @_;
2687 my ($field, $chart_id) = @$args{qw(field chart_id)};
2688 my $dbh = Bugzilla->dbh;
2690 if ($field eq 'keywords') {
2691 $args->{full_field} = 'keyworddefs.name';
2692 return "keywords INNER JOIN keyworddefs".
2693 " ON keywords.keywordid = keyworddefs.id";
2695 elsif ($field eq 'tag') {
2696 $args->{full_field} = 'tag.name';
2697 return "bug_tag INNER JOIN tag ON bug_tag.tag_id = tag.id AND user_id = "
2698 . ($self->_sharer_id || $self->_user->id);
2700 elsif ($field eq 'bug_group') {
2701 $args->{full_field} = 'groups.name';
2702 return "bug_group_map INNER JOIN groups
2703 ON bug_group_map.group_id = groups.id";
2705 elsif ($field eq 'blocked' or $field eq 'dependson') {
2706 my $select = $field eq 'blocked' ? 'dependson' : 'blocked';
2707 $args->{_select_field} = $select;
2708 $args->{full_field} = $field;
2709 return "dependencies";
2711 elsif ($field eq 'longdesc') {
2712 $args->{_extra_where} = " AND isprivate = 0"
2713 if !$self->_user->is_insider;
2714 $args->{full_field} = 'thetext';
2717 elsif ($field eq 'longdescs.isprivate') {
2718 ThrowUserError('auth_failure', { action => 'search',
2719 object => 'bug_fields',
2720 field => 'longdescs.isprivate' })
2721 if !$self->_user->is_insider;
2722 $args->{full_field} = 'isprivate';
2725 elsif ($field =~ /^attachments/) {
2726 $args->{_extra_where} = " AND isprivate = 0"
2727 if !$self->_user->is_insider;
2728 $field =~ /^attachments\.(.+)$/;
2729 $args->{full_field} = $1;
2730 return "attachments";
2732 elsif ($field eq 'attach_data.thedata') {
2733 $args->{_extra_where} = " AND attachments.isprivate = 0"
2734 if !$self->_user->is_insider;
2735 return "attachments INNER JOIN attach_data "
2736 . " ON attachments.attach_id = attach_data.id"
2738 elsif ($field eq 'flagtypes.name') {
2739 $args->{full_field} = $dbh->sql_string_concat("flagtypes.name",
2741 return "flags INNER JOIN flagtypes ON flags.type_id = flagtypes.id";
2743 my $table = "bug_$field";
2744 $args->{full_field} = "bug_$field.value";
2748 sub _multiselect_term {
2749 my ($self, $args, $not) = @_;
2750 my $table = $self->_multiselect_table($args);
2751 $self->_do_operator_function($args);
2752 my $term = $args->{term};
2753 $term .= $args->{_extra_where} || '';
2754 my $select = $args->{_select_field} || 'bug_id';
2755 return build_subselect("bugs.bug_id", $select, $table, $term, $not);
2758 ###############################
2759 # Standard Operator Functions #
2760 ###############################
2762 sub _simple_operator {
2763 my ($self, $args) = @_;
2764 my ($full_field, $quoted, $operator) =
2765 @$args{qw(full_field quoted operator)};
2766 my $sql_operator = SIMPLE_OPERATORS->{$operator};
2767 $args->{term} = "$full_field $sql_operator $quoted";
2770 sub _casesubstring {
2771 my ($self, $args) = @_;
2772 my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2773 my $dbh = Bugzilla->dbh;
2775 $args->{term} = $dbh->sql_position($quoted, $full_field) . " > 0";
2779 my ($self, $args) = @_;
2780 my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2781 my $dbh = Bugzilla->dbh;
2783 # XXX This should probably be changed to just use LIKE
2784 $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " > 0";
2788 my ($self, $args) = @_;
2789 my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2790 my $dbh = Bugzilla->dbh;
2792 # XXX This should probably be changed to just use NOT LIKE
2793 $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " = 0";
2797 my ($self, $args) = @_;
2798 my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2799 my $dbh = Bugzilla->dbh;
2801 $args->{term} = $dbh->sql_regexp($full_field, $quoted);
2805 my ($self, $args) = @_;
2806 my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2807 my $dbh = Bugzilla->dbh;
2809 $args->{term} = $dbh->sql_not_regexp($full_field, $quoted);
2813 my ($self, $args) = @_;
2814 my ($field, $full_field) = @$args{qw(field full_field)};
2815 my $dbh = Bugzilla->dbh;
2817 my @list = $self->_all_values($args, ',');
2818 @list = map { $self->_quote_unless_numeric($args, $_) } @list;
2821 $args->{term} = $dbh->sql_in($full_field, \@list);
2828 sub _anywordsubstr {
2829 my ($self, $args) = @_;
2831 my @terms = $self->_substring_terms($args);
2832 $args->{term} = join("\n\tOR ", @terms);
2835 sub _allwordssubstr {
2836 my ($self, $args) = @_;
2838 my @terms = $self->_substring_terms($args);
2839 $args->{term} = join("\n\tAND ", @terms);
2842 sub _nowordssubstr {
2843 my ($self, $args) = @_;
2844 $self->_anywordsubstr($args);
2845 my $term = $args->{term};
2846 $args->{term} = "NOT($term)";
2850 my ($self, $args) = @_;
2852 my @terms = $self->_word_terms($args);
2853 # Because _word_terms uses AND, we need to parenthesize its terms
2854 # if there are more than one.
2855 @terms = map("($_)", @terms) if scalar(@terms) > 1;
2856 $args->{term} = join("\n\tOR ", @terms);
2860 my ($self, $args) = @_;
2862 my @terms = $self->_word_terms($args);
2863 $args->{term} = join("\n\tAND ", @terms);
2867 my ($self, $args) = @_;
2868 $self->_anywords($args);
2869 my $term = $args->{term};
2870 $args->{term} = "NOT($term)";
2873 sub _changedbefore_changedafter {
2874 my ($self, $args) = @_;
2875 my ($chart_id, $joins, $field, $operator, $value) =
2876 @$args{qw(chart_id joins field operator value)};
2877 my $dbh = Bugzilla->dbh;
2879 my $field_object = $self->_chart_fields->{$field}
2880 || ThrowCodeError("invalid_field_name", { field => $field });
2882 # Asking when creation_ts changed is just asking when the bug was created.
2883 if ($field_object->name eq 'creation_ts') {
2885 $operator eq 'changedbefore' ? 'lessthaneq' : 'greaterthaneq';
2886 return $self->_do_operator_function($args);
2889 my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2890 my $field_id = $field_object->id;
2891 # Charts on changed* fields need to be field-specific. Otherwise,
2892 # OR chart rows make no sense if they contain multiple fields.
2893 my $table = "act_${field_id}_$chart_id";
2895 my $sql_date = $dbh->quote(SqlifyDate($value));
2897 table => 'bugs_activity',
2899 extra => ["$table.fieldid = $field_id",
2900 "$table.bug_when $sql_operator $sql_date"],
2903 $args->{term} = "$table.bug_when IS NOT NULL";
2904 $self->_changed_security_check($args, $join);
2905 push(@$joins, $join);
2908 sub _changedfrom_changedto {
2909 my ($self, $args) = @_;
2910 my ($chart_id, $joins, $field, $operator, $quoted) =
2911 @$args{qw(chart_id joins field operator quoted)};
2913 my $column = ($operator =~ /from/) ? 'removed' : 'added';
2914 my $field_object = $self->_chart_fields->{$field}
2915 || ThrowCodeError("invalid_field_name", { field => $field });
2916 my $field_id = $field_object->id;
2917 my $table = "act_${field_id}_$chart_id";
2919 table => 'bugs_activity',
2921 extra => ["$table.fieldid = $field_id",
2922 "$table.$column = $quoted"],
2925 $args->{term} = "$table.bug_when IS NOT NULL";
2926 $self->_changed_security_check($args, $join);
2927 push(@$joins, $join);
2931 my ($self, $args) = @_;
2932 my ($chart_id, $joins, $field, $operator, $value) =
2933 @$args{qw(chart_id joins field operator value)};
2935 my $field_object = $self->_chart_fields->{$field}
2936 || ThrowCodeError("invalid_field_name", { field => $field });
2937 my $field_id = $field_object->id;
2938 my $table = "act_${field_id}_$chart_id";
2939 my $user_id = login_to_id($value, THROW_ERROR);
2941 table => 'bugs_activity',
2943 extra => ["$table.fieldid = $field_id",
2944 "$table.who = $user_id"],
2947 $args->{term} = "$table.bug_when IS NOT NULL";
2948 $self->_changed_security_check($args, $join);
2949 push(@$joins, $join);
2952 sub _changed_security_check {
2953 my ($self, $args, $join) = @_;
2954 my ($chart_id, $field) = @$args{qw(chart_id field)};
2956 my $field_object = $self->_chart_fields->{$field}
2957 || ThrowCodeError("invalid_field_name", { field => $field });
2958 my $field_id = $field_object->id;
2960 # If the user is not part of the insiders group, they cannot see
2961 # changes to attachments (including attachment flags) that are private
2962 if ($field =~ /^(?:flagtypes\.name$|attach)/ and !$self->_user->is_insider) {
2963 $join->{then_to} = {
2964 as => "attach_${field_id}_$chart_id",
2965 table => 'attachments',
2966 from => "act_${field_id}_$chart_id.attach_id",
2970 $args->{term} .= " AND COALESCE(attach_${field_id}_$chart_id.isprivate, 0) = 0";
2974 ######################
2975 # Public Subroutines #
2976 ######################
2978 # Validate that the query type is one we can deal with
2979 sub IsValidQueryType
2981 my ($queryType) = @_;
2982 if (grep { $_ eq $queryType } qw(specific advanced)) {
2988 # Splits out "asc|desc" from a sort order item.
2989 sub split_order_term {
2990 my $fragment = shift;
2991 $fragment =~ /^(.+?)(?:\s+(ASC|DESC))?$/i;
2992 my ($column_name, $direction) = (lc($1), uc($2 || ''));
2993 return wantarray ? ($column_name, $direction) : $column_name;
2996 # Used to translate old SQL fragments from buglist.cgi's "order" argument
2997 # into our modern field IDs.
2998 sub translate_old_column {
3000 # All old SQL fragments have a period in them somewhere.
3001 return $column if $column !~ /\./;
3003 if ($column =~ /\bAS\s+(\w+)$/i) {
3006 # product, component, classification, assigned_to, qa_contact, reporter
3007 elsif ($column =~ /map_(\w+?)s?\.(login_)?name/i) {
3011 # If it doesn't match the regexps above, check to see if the old
3012 # SQL fragment matches the SQL of an existing column
3013 foreach my $key (%{ COLUMNS() }) {
3014 next unless exists COLUMNS->{$key}->{name};
3015 return $key if COLUMNS->{$key}->{name} eq $column;