Upgrade to Bugzilla 4.2.7.
[WebKit-https.git] / Websites / bugs.webkit.org / Bugzilla / Search.pm
1 # -*- Mode: perl; indent-tabs-mode: nil -*-
2 #
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/
7 #
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.
12 #
13 # The Original Code is the Bugzilla Bug Tracking System.
14 #
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
18 # Rights Reserved.
19 #
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>
33
34 use strict;
35
36 package Bugzilla::Search;
37 use base qw(Exporter);
38 @Bugzilla::Search::EXPORT = qw(
39     IsValidQueryType
40     split_order_term
41     translate_old_column
42 );
43
44 use Bugzilla::Error;
45 use Bugzilla::Util;
46 use Bugzilla::Constants;
47 use Bugzilla::Group;
48 use Bugzilla::User;
49 use Bugzilla::Field;
50 use Bugzilla::Search::Clause;
51 use Bugzilla::Search::Condition qw(condition);
52 use Bugzilla::Status;
53 use Bugzilla::Keyword;
54
55 use Data::Dumper;
56 use Date::Format;
57 use Date::Parse;
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);
62
63 # Description Of Boolean Charts
64 # -----------------------------
65 #
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.
72 #
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".
78 #
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.
83 #
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 # |------|------|------|------|
91 # | row3 |  c1  |      |      |
92 # -----------------------------
93 #
94 #        --------
95 #        | col2 |
96 # --------------|
97 # | row1 |  d1  | => (d1)
98 # ---------------
99 #
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)
102 #
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'.
108 #
109 # --------------------------------------------------------------
110 # CC    | equal to
111 # foo@blah.org
112 # --------------------------------------------------------------
113 # CC    | equal to
114 # bar@blah.org
115 #
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.
120 #
121 # --------------------------------------------------------------
122 # CC    | equal to
123 # foo@blah.org
124 # AND
125 # CC    | equal to
126 # bar@blah.org
127 # --------------------------------------------------------------
128
129 #############
130 # Constants #
131 #############
132
133 # When doing searches, NULL datetimes are treated as this date.
134 use constant EMPTY_DATETIME => '1970-01-01 00:00:00';
135
136 # This is the regex for real numbers from Regexp::Common, modified to be
137 # more readable.
138 use constant NUMBER_REGEX => qr/
139     ^[+-]?      # A sign, optionally.
140
141     (?=\d|\.)   # Then either a digit or "."
142     \d*         # Followed by many other digits
143     (?:
144         \.      # Followed possibly by some decimal places
145         (?:\d*)
146     )?
147  
148     (?:         # Followed possibly by an exponent.
149         [Ee]
150         [+-]?
151         \d+
152     )?
153     $
154 /x;
155
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,
165     regexp         => \&_regexp,
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,
185 };
186
187 # Some operators are really just standard SQL operators, and are
188 # all implemented by the _simple_operator function, which uses this
189 # constant.
190 use constant SIMPLE_OPERATORS => {
191     equals        => '=',
192     notequals     => '!=',
193     greaterthan   => '>',
194     greaterthaneq => '>=',
195     lessthan      => '<',
196     lessthaneq    => "<=",
197 };
198
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
201 # here.
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
213 };
214
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(
218     changedafter
219     changedbefore
220     changedfrom
221     changedto
222     regexp
223     notregexp
224 );
225
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,
232     
233     allwords       => \&_multiselect_multiple,
234     allwordssubstr => \&_multiselect_multiple,
235     anyexact       => \&_multiselect_multiple,
236     anywords       => \&_multiselect_multiple,
237     anywordssubstr => \&_multiselect_multiple,
238     
239     _non_changed    => \&_multiselect_nonchanged,
240 };
241
242 use constant OPERATOR_FIELD_OVERRIDE => {
243     # User fields
244     'attachments.submitter' => {
245         _non_changed => \&_user_nonchanged,
246     },
247     assigned_to => {
248         _non_changed => \&_user_nonchanged,
249     },
250     cc => {
251         _non_changed => \&_user_nonchanged,
252     },
253     commenter => {
254         _non_changed => \&_user_nonchanged,
255     },
256     reporter => {
257         _non_changed => \&_user_nonchanged,
258     },
259     'requestees.login_name' => {
260         _non_changed => \&_user_nonchanged,
261     },
262     'setters.login_name' => {
263         _non_changed => \&_user_nonchanged,    
264     },
265     qa_contact => {
266         _non_changed => \&_user_nonchanged,
267     },
268     
269     # General Bug Fields
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,
277     classification => {
278         _non_changed => \&_classification_nonchanged,
279     },
280     component => {
281         _non_changed => \&_component_nonchanged,
282     },
283     content => {
284         matches    => \&_content_matches,
285         notmatches => \&_content_matches,
286         _default   => sub { ThrowUserError("search_content_without_matches"); },
287     },
288     days_elapsed => {
289         _default => \&_days_elapsed,
290     },
291     dependson        => MULTI_SELECT_OVERRIDE,
292     keywords         => MULTI_SELECT_OVERRIDE,
293     'flagtypes.name' => {
294         _non_changed => \&_flagtypes_nonchanged,
295     },
296     longdesc => {
297         changedby     => \&_long_desc_changedby,
298         changedbefore => \&_long_desc_changedbefore_after,
299         changedafter  => \&_long_desc_changedbefore_after,
300         _non_changed  => \&_long_desc_nonchanged,
301     },
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,
309     },
310     'longdescs.isprivate' => MULTI_SELECT_OVERRIDE,
311     owner_idle_time => {
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,
317     },
318     product => {
319         _non_changed => \&_product_nonchanged,
320     },
321     tag => MULTI_SELECT_OVERRIDE,
322     
323     # Timetracking Fields
324     deadline => { _non_changed => \&_deadline },
325     percentage_complete => {
326         _non_changed => \&_percentage_complete,
327     },
328     work_time => {
329         changedby     => \&_work_time_changedby,
330         changedbefore => \&_work_time_changedbefore_after,
331         changedafter  => \&_work_time_changedbefore_after,
332         _default      => \&_work_time,
333     },
334     
335     # Custom Fields
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,    
342 };
343
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,
349     cc          => \&_cc_pronoun,
350     commenter   => \&_commenter_pronoun,
351     qa_contact  => \&_contact_pronoun,
352     reporter    => \&_contact_pronoun,
353     
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,
358 };
359
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' },
367         isprivate => 1,
368     },
369     cc => {
370         field => 'who',
371         join  => { table => 'cc' },
372     },
373     commenter => {
374         field => 'who',
375         join  => { table => 'longdescs', join => 'INNER' },
376         isprivate => 1,
377     },
378     qa_contact => {
379         nullable => 1,
380     },
381     'requestees.login_name' => {
382         nullable => 1,
383         field    => 'requestee_id',
384         join     => { table => 'flags' },
385     },
386     'setters.login_name' => {
387         field    => 'setter_id',
388         join     => { table => 'flags' },
389     },
390 };
391
392 # Backwards compatibility for times that we changed the names of fields
393 # or URL parameters.
394 use constant FIELD_MAP => {
395     'attachments.thedata' => 'attach_data.thedata',
396     bugidtype => 'bug_id_type',
397     changedin => 'days_elapsed',
398     long_desc => 'longdesc',
399 };
400
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'],
406         join  => {
407             table => 'milestones',
408             from  => 'target_milestone',
409             to    => 'value',
410             extra => ['bugs.product_id = map_target_milestone.product_id'],
411             join  => 'INNER',
412         }
413     },
414 };
415
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'],
421 };
422
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 => {
428     actual_time => {
429         table => '(SELECT bug_id, SUM(work_time) AS total'
430                  . ' FROM longdescs GROUP BY bug_id)',
431         join  => 'INNER',
432     },
433     assigned_to => {
434         from  => 'assigned_to',
435         to    => 'userid',
436         table => 'profiles',
437         join  => 'INNER',
438     },
439     reporter => {
440         from  => 'reporter',
441         to    => 'userid',
442         table => 'profiles',
443         join  => 'INNER',
444     },
445     qa_contact => {
446         from  => 'qa_contact',
447         to    => 'userid',
448         table => 'profiles',
449     },
450     component => {
451         from  => 'component_id',
452         to    => 'id',
453         table => 'components',
454         join  => 'INNER',
455     },
456     product => {
457         from  => 'product_id',
458         to    => 'id',
459         table => 'products',
460         join  => 'INNER',
461     },
462     classification => {
463         table => 'classifications',
464         from  => 'map_product.classification_id',
465         to    => 'id',
466         join  => 'INNER',
467     },
468     'flagtypes.name' => {
469         as    => 'map_flags',
470         table => 'flags',
471         extra => ['map_flags.attach_id IS NULL'],
472         then_to => {
473             as    => 'map_flagtypes',
474             table => 'flagtypes',
475             from  => 'map_flags.type_id',
476             to    => 'id',
477         },
478     },
479     keywords => {
480         table => 'keywords',
481         then_to => {
482             as    => 'map_keyworddefs',
483             table => 'keyworddefs',
484             from  => 'map_keywords.keywordid',
485             to    => 'id',
486         },
487     },
488     'longdescs.count' => {
489         table => 'longdescs',
490         join  => 'INNER',
491     },
492 };
493
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
496 # fields:
497 #
498 # 1. id: a unique identifier by which the column is referred in code;
499 #
500 # 2. name: The name of the column in the database (may also be an expression
501 #          that returns the value of the column);
502 #
503 # 3. title: The title of the column as displayed to users.
504
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.
508 #
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
513 # subroutine.
514 sub COLUMNS {
515     my $invocant = shift;
516     my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
517     my $dbh = Bugzilla->dbh;
518     my $cache = Bugzilla->request_cache;
519
520     if (defined $cache->{search_columns}->{$user->id}) {
521         return $cache->{search_columns}->{$user->id};
522     }
523
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.)
527     my %columns = (
528         relevance            => { title => 'Relevance'  },
529         assigned_to_realname => { title => 'Assignee'   },
530         reporter_realname    => { title => 'Reporter'   },
531         qa_contact_realname  => { title => 'QA Contact' },
532     );
533
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)";
537     my %special_sql = (
538         deadline    => $dbh->sql_date_format('bugs.deadline', '%Y-%m-%d'),
539         actual_time => 'map_actual_time.total',
540
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
544         # numbers.
545         percentage_complete =>
546             "(CASE WHEN $total_time = 0"
547                . " THEN 0"
548                . " ELSE FLOOR(100 * (map_actual_time.total / $total_time))"
549                 . " END)",
550
551         'flagtypes.name' => $dbh->sql_group_concat('DISTINCT ' 
552             . $dbh->sql_string_concat('map_flagtypes.name', 'map_flags.status')),
553
554         'keywords' => $dbh->sql_group_concat('DISTINCT map_keyworddefs.name'),
555         
556         'longdescs.count' => 'COUNT(DISTINCT map_longdescs_count.comment_id)',
557     );
558
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).
564     my %old_names = (
565         creation_ts => 'opendate',
566         delta_ts    => 'changeddate',
567         work_time   => 'actual_time',
568     );
569
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);
575
576     foreach my $col (@email_fields) {
577         my $sql = "map_${col}.login_name";
578         if (!$user->id) {
579              $sql = $dbh->sql_string_until($sql, $dbh->quote('@'));
580         }
581         $special_sql{$col} = $sql;
582         $columns{"${col}_realname"}->{name} = "map_${col}.realname";
583     }
584
585     foreach my $col (@id_fields) {
586         $special_sql{$col} = "map_${col}.name";
587     }
588
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};
594         my $sql;
595         if (exists $special_sql{$id}) {
596             $sql = $special_sql{$id};
597         }
598         elsif ($field->type == FIELD_TYPE_MULTI_SELECT) {
599             $sql = $dbh->sql_group_concat(
600                 'DISTINCT map_' . $field->name . '.value');
601         }
602         else {
603             $sql = 'bugs.' . $field->name;
604         }
605         $columns{$id} = { name => $sql, title => $field->description };
606     }
607
608     # The short_short_desc column is identical to short_desc
609     $columns{'short_short_desc'} = $columns{'short_desc'};
610
611     Bugzilla::Hook::process('buglist_columns', { columns => \%columns });
612
613     $cache->{search_columns}->{$user->id} = \%columns;
614     return $cache->{search_columns}->{$user->id};
615 }
616
617 sub REPORT_COLUMNS {
618     my $invocant = shift;
619     my $user = blessed($invocant) ? $invocant->_user : Bugzilla->user;
620
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);
628
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);
633
634     # If you're not a time-tracker, you can't use time-tracking
635     # columns.
636     if (!$user->is_timetracker) {
637         push(@no_report_columns, TIMETRACKING_FIELDS);
638     }
639
640     foreach my $name (@no_report_columns) {
641         delete $columns->{$name};
642     }
643     return $columns;
644 }
645
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(
650     bug_id
651     flagtypes.name
652     keywords
653     longdescs.count
654     percentage_complete
655 );
656
657 ###############
658 # Constructor #
659 ###############
660
661 # Note that the params argument may be modified by Bugzilla::Search
662 sub new {
663     my $invocant = shift;
664     my $class = ref($invocant) || $invocant;
665   
666     my $self = { @_ };
667     bless($self, $class);
668     $self->{'user'} ||= Bugzilla->user;
669     
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).
674     #
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
678     # or not.
679     my $params_in = $self->_params;
680     my %params = map { $_ => $params_in->{$_} } keys %$params_in;
681     $self->{params} = \%params;
682
683     return $self;
684 }
685
686
687 ####################
688 # Public Accessors #
689 ####################
690
691 sub sql {
692     my ($self) = @_;
693     return $self->{sql} if $self->{sql};
694     my $dbh = Bugzilla->dbh;
695
696     my ($joins, $clause) = $self->_charts_to_conditions();
697
698     if (!$clause->as_string
699         && !Bugzilla->params->{'search_allow_no_criteria'}
700         && !$self->{allow_unlimited})
701     {
702         ThrowUserError('buglist_parameters_required');
703     }
704
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;
713     
714     my $query = <<END;
715 SELECT $select
716   FROM $from
717  WHERE $where
718 $group_by$order_by$limit
719 END
720     $self->{sql} = $query;
721     return $self->{sql};
722 }
723
724 sub search_description {
725     my ($self, $params) = @_;
726     my $desc = $self->{'search_description'} ||= [];
727     if ($params) {
728         push(@$desc, $params);
729     }
730     # Make sure that the description has actually been generated if
731     # people are asking for the whole thing.
732     else {
733         $self->sql;
734     }
735     return $self->{'search_description'};
736 }
737
738 sub boolean_charts_to_custom_search {
739     my ($self, $cgi_buffer) = @_;
740     my @as_params = $self->_boolean_charts->as_params;
741
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
744     # numerically.
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);
752         }
753         $count++;
754     }
755 }
756
757 ######################
758 # Internal Accessors #
759 ######################
760
761 # Fields that are legal for boolean charts of any kind.
762 sub _chart_fields {
763     my ($self) = @_;
764
765     if (!$self->{chart_fields}) {
766         my $chart_fields = Bugzilla->fields({ by_name => 1 });
767
768         if (!$self->_user->is_timetracker) {
769             foreach my $tt_field (TIMETRACKING_FIELDS) {
770                 delete $chart_fields->{$tt_field};
771             }
772         }
773         $self->{chart_fields} = $chart_fields;
774     }
775     return $self->{chart_fields};
776 }
777
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 {
782     my ($self) = @_;
783     $self->{multi_select_fields} ||= Bugzilla->fields({
784         by_name => 1,
785         type    => [FIELD_TYPE_MULTI_SELECT, FIELD_TYPE_BUG_URLS]});
786     return $self->{multi_select_fields};
787 }
788
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,
791 # always.
792 sub _param_array {
793     my ($self, $name) = @_;
794     my $value = $self->_params->{$name};
795     if (!defined $value) {
796         return ();
797     }
798     if (ref($value) eq 'ARRAY') {
799         return @$value;
800     }
801     return ($value);
802 }
803
804 sub _params { $_[0]->{params} }
805 sub _user { return $_[0]->{user} }
806 sub _sharer_id { $_[0]->{sharer} }
807
808 ##############################
809 # Internal Accessors: SELECT #
810 ##############################
811
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'} || [] } }
815
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.
818 sub _extra_columns {
819     my ($self) = @_;
820     # Everything that's going to be in the ORDER BY must also be
821     # in the SELECT.
822     push(@{ $self->{extra_columns} }, $self->_input_order_columns);
823     return @{ $self->{extra_columns} };
824 }
825
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);
832 }
833
834 # These are the columns that we're going to be actually SELECTing.
835 sub _display_columns {
836     my ($self) = @_;
837     return @{ $self->{display_columns} } if $self->{display_columns};
838
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;
843
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}++;
848     }
849     $self->{display_columns} = \@columns;
850     return @{ $self->{display_columns} };
851 }
852
853 # These are the columns that are involved in the query.
854 sub _select_columns {
855     my ($self) = @_;
856     return @{ $self->{select_columns} } if $self->{select_columns};
857
858     my @select_columns;
859     foreach my $column ($self->_display_columns) {
860         if (my $add_first = COLUMN_DEPENDS->{$column}) {
861             push(@select_columns, @$add_first);
862         }
863         push(@select_columns, $column);
864     }
865     # Remove duplicated columns.
866     $self->{select_columns} = [uniq @select_columns];
867     return @{ $self->{select_columns} };
868 }
869
870 # This takes _display_columns and translates it into the actual SQL that
871 # will go into the SELECT clause.
872 sub _sql_select {
873     my ($self) = @_;
874     my @sql_fields;
875     foreach my $column ($self->_display_columns) {
876         my $alias = $column;
877         # Aliases cannot contain dots in them. We convert them to underscores.
878         $alias =~ s/\./_/g;
879         my $sql = $self->COLUMNS->{$column}->{name} . " AS $alias";
880         push(@sql_fields, $sql);
881     }
882     return @sql_fields;
883 }
884
885 ################################
886 # Internal Accessors: ORDER BY #
887 ################################
888
889 # The "order" that was requested by the consumer, exactly as it was
890 # requested.
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 {
894     my ($self) = @_;
895     return map { (split_order_term($_))[0] } $self->_input_order;
896 }
897
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.
900 sub _special_order {
901     my ($self) = @_;
902     return $self->{special_order} if $self->{special_order};
903     
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"],
911             join  => {
912                 table => $name,
913                 from  => "bugs.$name",
914                 to    => "value",
915                 join  => 'INNER',
916             }
917         };
918     }
919     $self->{special_order} = \%special_order;
920     return $self->{special_order};
921 }
922
923 sub _sql_order_by {
924     my ($self) = @_;
925     if (!$self->{sql_order_by}) {
926         my @order_by = map { $self->_translate_order_by_column($_) }
927                            $self->_input_order;
928         $self->{sql_order_by} = \@order_by;
929     }
930     return @{ $self->{sql_order_by} };
931 }
932
933 sub _translate_order_by_column {
934     my ($self, $order_by_item) = @_;
935
936     my ($field, $direction) = split_order_term($order_by_item);
937     
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).
942     $field =~ s/\./_/g;
943     my @items = $special_order ? @$special_order : $field;
944     if (lc($direction) eq 'desc') {
945         @items = map { "$_ DESC" } @items;
946     }
947     return @items;
948 }
949
950 #############################
951 # Internal Accessors: LIMIT #
952 #############################
953
954 sub _sql_limit {
955     my ($self) = @_;
956     my $limit = $self->_params->{limit};
957     my $offset = $self->_params->{offset};
958     
959     my $max_results = Bugzilla->params->{'max_search_results'};
960     if (!$self->{allow_unlimited} && (!$limit || $limit > $max_results)) {
961         $limit = $max_results;
962     }
963     
964     if (defined($offset) && !$limit) {
965         $limit = INT_MAX;
966     }
967     if (defined $limit) {
968         detaint_natural($limit) 
969             || ThrowCodeError('param_must_be_numeric', 
970                               { function => 'Bugzilla::Search::new',
971                                 param    => 'limit' });
972         if (defined $offset) {
973             detaint_natural($offset)
974                 || ThrowCodeError('param_must_be_numeric',
975                                   { function => 'Bugzilla::Search::new',
976                                     param    => 'offset' });
977         }
978         return Bugzilla->dbh->sql_limit($limit, $offset);
979     }
980     return '';
981 }
982
983 ############################
984 # Internal Accessors: FROM #
985 ############################
986
987 sub _column_join {
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};
993     if ($join_info) {
994         # Don't allow callers to modify the constant.
995         $join_info = dclone($join_info);
996     }
997     else {
998         if ($self->_multi_select_fields->{$field}) {
999             $join_info = { table => "bug_$field" };
1000         }
1001     }
1002     if ($join_info and !$join_info->{as}) {
1003         $join_info = dclone($join_info);
1004         $join_info->{as} = "map_$field";
1005     }
1006     return $join_info ? $join_info : ();
1007 }
1008
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) = @_;
1013     my @result;
1014     while(my $join = shift @$joins) {
1015         my $name = $join->{as};
1016         my ($others_like_me, $the_rest) = part { $_->{as} eq $name ? 0 : 1 }
1017                                                @$joins;
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.
1023             if ($from) {
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);
1027             }
1028             if ($to) {
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);
1032             }
1033             
1034             # We don't need to call uniq here--translate_join will do that
1035             # for us.
1036             my @conditions = map { @{ $_->{extra} || [] } }
1037                                  ($join, @$others_like_me);
1038             $join->{extra} = \@conditions;
1039             $joins = $the_rest;
1040         }
1041         push(@result, $join);
1042     }
1043     
1044     return @result;
1045 }
1046
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) = @_;
1052     my @result;
1053     foreach my $join (@$joins) {
1054         push(@result, $join);
1055         if (my $then_to = $join->{then_to}) {
1056             push(@result, $then_to);
1057         }
1058     }
1059     return @result;
1060 }
1061
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 {
1066     my ($self) = @_;
1067     my @joins;
1068     foreach my $field ($self->_select_columns) {
1069         my @column_join = $self->_column_join($field);
1070         push(@joins, @column_join);
1071     }
1072     foreach my $field ($self->_input_order_columns) {
1073         my $join_info = $self->_special_order->{$field}->{join};
1074         if ($join_info) {
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";
1079             }
1080             push(@joins, $join_info);
1081         }
1082     }
1083     return @joins;
1084 }
1085
1086 # These are the joins that are *always* in the FROM clause.
1087 sub _standard_joins {
1088     my ($self) = @_;
1089     my $user = $self->_user;
1090     my @joins;
1091
1092     my $security_join = {
1093         table => 'bug_group_map',
1094         as    => 'security_map',
1095     };
1096     push(@joins, $security_join);
1097
1098     if ($user->id) {
1099         $security_join->{extra} =
1100             ["NOT (" . $user->groups_in_sql('security_map.group_id') . ")"];
1101             
1102         my $security_cc_join = {
1103             table => 'cc',
1104             as    => 'security_cc',
1105             extra => ['security_cc.who = ' . $user->id],
1106         };
1107         push(@joins, $security_cc_join);
1108     }
1109     
1110     return @joins;
1111 }
1112
1113 sub _sql_from {
1114     my ($self, $joins_input) = @_;
1115     my @joins = ($self->_standard_joins, $self->_select_order_joins,
1116                  @$joins_input);
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);
1121 }
1122
1123 # This takes a join data structure and turns it into actual JOIN SQL.
1124 sub _translate_join {
1125     my ($self, $join_info) = @_;
1126     
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};
1129         
1130     my $from_table = "bugs";
1131     my $from  = $join_info->{from} || "bug_id";
1132     if ($from =~ /^(\w+)\.(\w+)$/) {
1133         ($from_table, $from) = ($1, $2);
1134     }
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} || [] };
1140     $name =~ s/\./_/g;
1141     
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";
1149     }
1150
1151     my @join_sql = "$join JOIN $table AS $name"
1152                         . " ON $from_table.$from = $name.$to$extra_condition";
1153     return @join_sql;
1154 }
1155
1156 #############################
1157 # Internal Accessors: WHERE #
1158 #############################
1159
1160 # Note: There's also quite a bit of stuff that affects the WHERE clause
1161 # in the "Internal Accessors: Boolean Charts" section.
1162
1163 # The terms that are always in the WHERE clause. These implement bug
1164 # group security.
1165 sub _standard_where {
1166     my ($self) = @_;
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');
1174     
1175     my $security_term = 'security_map.group_id IS NULL';
1176
1177     my $user = $self->_user;
1178     if ($user->id) {
1179         my $userid = $user->id;
1180         # This indentation makes the resulting SQL more readable.
1181         $security_term .= <<END;
1182
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
1186 END
1187         if (Bugzilla->params->{'useqacontact'}) {
1188             $security_term.= "        OR bugs.qa_contact = $userid";
1189         }
1190         $security_term = "($security_term)";
1191     }
1192
1193     push(@where, $security_term);
1194
1195     return @where;
1196 }
1197
1198 sub _sql_where {
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;
1205     return $where;
1206 }
1207
1208 ################################
1209 # Internal Accessors: GROUP BY #
1210 ################################
1211
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.
1214 sub _sql_group_by {
1215     my ($self) = @_;
1216
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.
1219     my @extra_group_by;
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);
1224     }
1225
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);
1232     }
1233     
1234     @extra_group_by = uniq @extra_group_by;
1235     
1236     # bug_id is the only field we actually group by.
1237     return ('bugs.bug_id', join(',', @extra_group_by));
1238 }
1239
1240 # A helper for _sql_group_by.
1241 sub _skip_group_by {
1242     my ($self) = @_;
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};
1249 }
1250
1251 ##############################################
1252 # Internal Accessors: Special Params Parsing #
1253 ##############################################
1254
1255 # Backwards compatibility for old field names.
1256 sub _convert_old_params {
1257     my ($self) = @_;
1258     my $params = $self->_params;
1259     
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';
1264     }
1265     
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};
1270         }
1271     }
1272 }
1273
1274 # This parses all the standard search parameters except for the boolean
1275 # charts.
1276 sub _special_charts {
1277     my ($self) = @_;
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() );
1286     return $clause;
1287 }
1288
1289 sub _parse_basic_fields {
1290     my ($self) = @_;
1291     my $params = $self->_params;
1292     my $chart_fields = $self->_chart_fields;
1293     
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);
1301         next if !@values;
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};
1309         my $pass_value;
1310         if ($field->is_select or $field->name eq 'version'
1311             or $field->name eq 'target_milestone')
1312         {
1313             $pass_value = \@values;
1314         }
1315         else {
1316             $pass_value = join(',', @values);
1317         }
1318         $clause->add($field_name, $operator, $pass_value);
1319     }
1320     return $clause;
1321 }
1322
1323 sub _special_parse_bug_status {
1324     my ($self) = @_;
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');
1332
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;
1336
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);
1343     }
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);
1348     }
1349
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);
1355
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'};
1360     }
1361     else {
1362         $params->{'bug_status'} = \@bug_status;
1363     }
1364 }
1365
1366 sub _special_parse_chfield {
1367     my ($self) = @_;
1368     my $params = $self->_params;
1369     
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;
1377
1378     @fields = map { $_ eq '[Bug creation]' ? 'creation_ts' : $_ } @fields;
1379
1380     my $clause = new Bugzilla::Search::Clause();
1381
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);
1390     }
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
1396     # work.
1397     if ($date_to ne '' and !@fields and $value_to eq '') {
1398         $clause->add('delta_ts', 'lessthaneq', $date_to);
1399     }
1400
1401     # Basically, we construct the chart like:
1402     #
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)
1406     #
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.
1411     
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);
1416         }
1417         $clause->add($value_clause);
1418     }
1419
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);
1424         }
1425         $clause->add($from_clause);
1426     }
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}$/;
1432
1433         my $to_clause = new Bugzilla::Search::Clause('OR');
1434         foreach my $field (@fields) {
1435             $to_clause->add($field, 'changedbefore', $date_to);
1436         }
1437         $clause->add($to_clause);
1438     }
1439
1440     return $clause;
1441 }
1442
1443 sub _special_parse_deadline {
1444     my ($self) = @_;
1445     return if !$self->_user->is_timetracker;
1446     my $params = $self->_params;
1447     
1448     my $clause = new Bugzilla::Search::Clause();
1449     if (my $from = $params->{'deadlinefrom'}) {
1450         $clause->add('deadline', 'greaterthaneq', $from);
1451     }
1452     if (my $to = $params->{'deadlineto'}) {
1453         $clause->add('deadline', 'lessthaneq', $to);
1454     }
1455     
1456     return $clause;
1457 }
1458
1459 sub _special_parse_email {
1460     my ($self) = @_;
1461     my $params = $self->_params;
1462     
1463     my @email_params = grep { $_ =~ /^email\d+$/ } keys %$params;
1464     
1465     my $clause = new Bugzilla::Search::Clause();
1466     foreach my $param (@email_params) {
1467         $param =~ /(\d+)$/;
1468         my $id = $1;
1469         my $email = trim($params->{"email$id"});
1470         next if !$email;
1471         my $type = $params->{"emailtype$id"} || 'anyexact';
1472         $type = "anyexact" if $type eq "exact";
1473
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);
1478             }
1479         }
1480         if ($params->{"emaillongdesc$id"}) {
1481             $or_clause->add("commenter", $type, $email);
1482         }
1483         
1484         $clause->add($or_clause);
1485     }
1486     
1487     return $clause;
1488 }
1489
1490 sub _special_parse_resolution {
1491     my ($self) = @_;
1492     my $params = $self->_params;
1493     return if !defined $params->{'resolution'};
1494
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'};
1500     }
1501 }
1502
1503 sub _valid_values {
1504     my ($input, $valid, $extra_value) = @_;
1505     my @result;
1506     foreach my $item (@$input) {
1507         $item = trim($item);
1508         if (defined $extra_value and $item eq $extra_value) {
1509             push(@result, $item);
1510         }
1511         elsif (grep { $_->name eq $item } @$valid) {
1512             push(@result, $item);
1513         }
1514     }
1515     return @result;
1516 }
1517
1518 ######################################
1519 # Internal Accessors: Boolean Charts #
1520 ######################################
1521
1522 sub _charts_to_conditions {
1523     my ($self) = @_;
1524     
1525     my $clause = $self->_charts;
1526     my @joins;
1527     $clause->walk_conditions(sub {
1528         my ($condition) = @_;
1529         return if !$condition->translated;
1530         push(@joins, @{ $condition->translated->{joins} });
1531     });
1532     return (\@joins, $clause);
1533 }
1534
1535 sub _charts {
1536     my ($self) = @_;
1537     
1538     my $clause = $self->_params_to_data_structure();
1539     my $chart_id = 0;
1540     $clause->walk_conditions(sub { $self->_handle_chart($chart_id++, @_) });
1541     return $clause;
1542 }
1543
1544 sub _params_to_data_structure {
1545     my ($self) = @_;
1546     
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
1549     # happen first.
1550     my $clause = $self->_special_charts;
1551
1552     # Then we process the old Boolean Charts input format.
1553     $clause->add( $self->_boolean_charts );
1554     
1555     # And then process the modern "custom search" format.
1556     $clause->add( $self->_custom_search );
1557    
1558     return $clause;
1559 }
1560
1561 sub _boolean_charts {
1562     my ($self) = @_;
1563     
1564     my $params = $self->_params;
1565     my @param_list = keys %$params;
1566     
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;
1570     
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;
1576         
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;
1582             
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);
1590             }
1591             $and_clause->add($or_clause);
1592             $and_clause->negate(1) if $params->{"negate$chart_id"};
1593         }
1594         $clause->add($and_clause);
1595     }
1596     
1597     return $clause;
1598 }
1599
1600 sub _custom_search {
1601     my ($self) = @_;
1602     my $params = $self->_params;
1603
1604     my $current_clause = new Bugzilla::Search::Clause($params->{j_top});
1605     my @clause_stack;
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;
1615             next;
1616         }
1617         if ($field eq 'CP') {
1618             $current_clause = pop @clause_stack;
1619             ThrowCodeError('search_cp_without_op', { id => $id })
1620                 if !$current_clause;
1621             next;
1622         }
1623         
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);
1629     }
1630     
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
1633     # $current_clause.
1634     return $clause_stack[0] || $current_clause;
1635 }
1636
1637 sub _field_ids {
1638     my ($self) = @_;
1639     my $params = $self->_params;
1640     my @param_list = keys %$params;
1641     
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;
1645     return @field_ids;
1646 }
1647
1648 sub _handle_chart {
1649     my ($self, $chart_id, $condition) = @_;
1650     my $dbh = Bugzilla->dbh;
1651     my $params = $self->_params;
1652     my ($field, $operator, $value) = $condition->fov;
1653
1654     $field = FIELD_MAP->{$field} || $field;
1655
1656     return if (!defined $field or !defined $operator or !defined $value);
1657     
1658     my $string_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;
1663         return if !@$value;
1664         $string_value = join(',', @$value);
1665     }
1666     else {
1667         return if $value eq '';
1668         $string_value = $value;
1669     }
1670     
1671     $self->_chart_fields->{$field}
1672         or ThrowCodeError("invalid_field_name", { field => $field });
1673     trick_taint($field);
1674     
1675     # This is the field as you'd reference it in a SQL statement.
1676     my $full_field = $field =~ /\./ ? $field : "bugs.$field";
1677
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.
1682     #
1683     # all_values is for search functions that do operate
1684     # on multiple values, like anyexact.
1685     
1686     my %search_args = (
1687         chart_id   => $chart_id,
1688         sequence   => $chart_id,
1689         field      => $field,
1690         full_field => $full_field,
1691         operator   => $operator,
1692         value      => $string_value,
1693         all_values => $value,
1694         joins      => [],
1695         condition  => $condition,
1696     );
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);
1700
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};
1704
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},
1711     });
1712     
1713     $condition->translated(\%search_args);
1714 }
1715
1716 ##################################
1717 # do_search_function And Helpers #
1718 ##################################
1719
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)};
1725     
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
1730         # parsing.
1731         return if $args->{term};
1732     }
1733     
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};
1740     }
1741     # If there's still no override, check for an override on the field's type.
1742     if (!$override) {
1743         my $field_obj = $self->_chart_fields->{$field};
1744         $override = $operator_field_override->{$field_obj->type};
1745     }
1746     
1747     if ($override) {
1748         my $search_func = $self->_pick_override_function($override, $operator);
1749         $self->$search_func($args) if $search_func;
1750     }
1751
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);
1757     }
1758     
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 });
1765     }
1766 }
1767
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);
1777 }
1778
1779 sub _reverse_operator {
1780     my ($self, $operator) = @_;
1781     my $reverse = OPERATOR_REVERSE->{$operator};
1782     return $reverse if $reverse;
1783     if ($operator =~ s/^not//) {
1784         return $operator;
1785     }
1786     return "not$operator";
1787 }
1788
1789 sub _pick_override_function {
1790     my ($self, $override, $operator) = @_;
1791     my $search_func = $override->{$operator};
1792
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
1797         #               "changed" in it
1798         # _default: Overrides all operators that aren't explicitly specified.
1799         if ($override->{_non_changed} and $operator !~ /changed/) {
1800             $search_func = $override->{_non_changed};
1801         }
1802         elsif ($override->{_default}) {
1803             $search_func = $override->{_default};
1804         }
1805     }
1806
1807     return $search_func;
1808 }
1809
1810 sub _get_operator_field_override {
1811     my $self = shift;
1812     my $cache = Bugzilla->request_cache;
1813
1814     return $cache->{operator_field_override} 
1815         if defined $cache->{operator_field_override};
1816
1817     my %operator_field_override = %{ OPERATOR_FIELD_OVERRIDE() };
1818     Bugzilla::Hook::process('search_operator_field_override',
1819                             { search => $self, 
1820                               operators => \%operator_field_override });
1821
1822     $cache->{operator_field_override} = \%operator_field_override;
1823     return $cache->{operator_field_override};
1824 }
1825
1826 sub _get_column_joins {
1827     my $self = shift;
1828     my $cache = Bugzilla->request_cache;
1829
1830     return $cache->{column_joins} if defined $cache->{column_joins};
1831
1832     my %column_joins = %{ COLUMN_JOINS() };
1833     Bugzilla::Hook::process('buglist_column_joins',
1834                             { column_joins => \%column_joins });
1835
1836     $cache->{column_joins} = \%column_joins;
1837     return $cache->{column_joins};
1838 }
1839
1840 ###########################
1841 # Search Function Helpers #
1842 ###########################
1843
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};
1852     }
1853     my ($field, $operator) = @$args{qw(field operator)};
1854     
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;
1859     if ($is_numeric) {
1860         my $quoted = $value;
1861         trick_taint($quoted);
1862         return $quoted;
1863     }
1864     return Bugzilla->dbh->quote($value);
1865 }
1866
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);
1876 }
1877
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
1881 # commas).
1882 sub _all_values {
1883     my ($self, $args, $split_on) = @_;
1884     $split_on ||= qr/[\s,]+/;
1885     my $dbh = Bugzilla->dbh;
1886     my $all_values = $args->{all_values};
1887     
1888     my @array;
1889     if (ref $all_values eq 'ARRAY') {
1890         @array = @$all_values;
1891     }
1892     else {
1893         @array = split($split_on, $all_values);
1894         @array = map { trim($_) } @array;
1895         @array = grep { defined $_ and $_ ne '' } @array;
1896     }
1897     
1898     if ($args->{field} eq 'resolution') {
1899         @array = map { $_ eq '---' ? '' : $_ } @array;
1900     }
1901     
1902     return @array;
1903 }
1904
1905 # Support for "any/all/nowordssubstr" comparison type ("words as substrings")
1906 sub _substring_terms {
1907     my ($self, $args) = @_;
1908     my $dbh = Bugzilla->dbh;
1909
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" }
1916                     @words;
1917     return @terms;
1918 }
1919
1920 sub _word_terms {
1921     my ($self, $args) = @_;
1922     my $dbh = Bugzilla->dbh;
1923     
1924     my @values = split(/[\s,]+/, $args->{value});
1925     @values = grep { defined $_ and $_ ne '' } @values;
1926     my @substring_terms = $self->_substring_terms($args);
1927     
1928     my @terms;
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
1935         # sure it's valid.
1936         my $regex_term = $dbh->sql_regexp($args->{full_field}, $quoted,
1937                                           'no check');
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");
1944     }
1945     
1946     return @terms;
1947 }
1948
1949 #####################################
1950 # "Special Parsing" Functions: Date #
1951 #####################################
1952
1953 sub _timestamp_translate {
1954     my ($self, $args) = @_;
1955     my $value = $args->{value};
1956     my $dbh = Bugzilla->dbh;
1957
1958     return if $value !~ /^(?:[\+\-]?\d+[hdwmy]s?|now)$/i;
1959
1960     # By default, the time is appended to the date, which we don't want
1961     # for deadlines.
1962     $value = SqlifyDate($value);
1963     if ($args->{field} eq 'deadline') {
1964         ($value) = split(/\s/, $value);
1965     }
1966     $args->{value} = $value;
1967     $args->{quoted} = $dbh->quote($value);
1968 }
1969
1970 sub SqlifyDate {
1971     my ($str) = @_;
1972     my $fmt = "%Y-%m-%d %H:%M:%S";
1973     $str = "" if (!defined $str || lc($str) eq 'now');
1974     if ($str eq "") {
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);
1977     }
1978
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;
1987             $unit = 'd';
1988         }
1989         if ($unit eq 'd') {
1990             if ($startof) {
1991               $fmt = "%Y-%m-%d 00:00:00";
1992               $date -= $sec + 60*$min + 3600*$hour;
1993             }
1994             $date -= 24*3600*$amount;
1995             return time2str($fmt, $date);
1996         }
1997         elsif ($unit eq 'y') {
1998             if ($startof) {
1999                 return sprintf("%4d-01-01 00:00:00", $year+1900-$amount);
2000             } 
2001             else {
2002                 return sprintf("%4d-%02d-%02d %02d:%02d:%02d", 
2003                                $year+1900-$amount, $month+1, $mday, $hour, $min, $sec);
2004             }
2005         }
2006         elsif ($unit eq 'm') {
2007             $month -= $amount;
2008             while ($month<0) { $year--; $month += 12; }
2009             if ($startof) {
2010                 return sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1);
2011             }
2012             else {
2013                 return sprintf("%4d-%02d-%02d %02d:%02d:%02d", 
2014                                $year+1900, $month+1, $mday, $hour, $min, $sec);
2015             }
2016         }
2017         elsif ($unit eq 'h') {
2018             # Special case for 'beginning of an hour'
2019             if ($startof) {
2020                 $fmt = "%Y-%m-%d %H:00:00";
2021             } 
2022             $date -= 3600*$amount;
2023             return time2str($fmt, $date);
2024         }
2025         return undef;                      # should not happen due to regexp at top
2026     }
2027     my $date = str2time($str);
2028     if (!defined($date)) {
2029         ThrowUserError("illegal_date", { date => $str });
2030     }
2031     return time2str($fmt, $date);
2032 }
2033
2034 ######################################
2035 # "Special Parsing" Functions: Users #
2036 ######################################
2037
2038 sub pronoun {
2039     my ($noun, $user) = (@_);
2040     if ($noun eq "%user%") {
2041         if ($user->id) {
2042             return $user->id;
2043         } else {
2044             ThrowUserError('login_required_for_pronoun');
2045         }
2046     }
2047     if ($noun eq "%reporter%") {
2048         return "bugs.reporter";
2049     }
2050     if ($noun eq "%assignee%") {
2051         return "bugs.assigned_to";
2052     }
2053     if ($noun eq "%qacontact%") {
2054         return "COALESCE(bugs.qa_contact,0)";
2055     }
2056     return 0;
2057 }
2058
2059 sub _contact_pronoun {
2060     my ($self, $args) = @_;
2061     my $value = $args->{value};
2062     my $user = $self->_user;
2063
2064     if ($value =~ /^\%group\.[^%]+%$/) {
2065         $self->_contact_exact_group($args);
2066     }
2067     elsif ($value =~ /^(%\w+%)$/) {
2068         $args->{value} = pronoun($1, $user);
2069         $args->{quoted} = $args->{value};
2070         $args->{value_is_id} = 1;
2071     }
2072 }
2073
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;
2080     
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();
2092
2093     my $group_ids = Bugzilla::Group->flatten_group_membership($group->id);
2094     my $table = "user_group_map_$chart_id";
2095     my $join = {
2096         table => 'user_group_map',
2097         as    => $table,
2098         from  => $field,
2099         to    => 'user_id',
2100         extra => [$dbh->sql_in("$table.group_id", $group_ids),
2101                   "$table.isbless = 0"],
2102     };
2103     push(@$joins, $join);
2104     if ($operator =~ /^not/) {
2105         $args->{term} = "$table.group_id IS NULL";
2106     }
2107     else {
2108         $args->{term} = "$table.group_id IS NOT NULL";
2109     }
2110 }
2111
2112 sub _cc_pronoun {
2113     my ($self, $args) = @_;
2114     my ($full_field, $value) = @$args{qw(full_field value)};
2115     my $user = $self->_user;
2116
2117     if ($value =~ /\%group/) {
2118         return $self->_cc_exact_group($args);
2119     }
2120     elsif ($value =~ /^(%\w+%)$/) {
2121         $args->{value} = pronoun($1, $user);
2122         $args->{quoted} = $args->{value};
2123         $args->{value_is_id} = 1;
2124     }
2125 }
2126
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;
2133     
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});
2139
2140     my $all_groups = Bugzilla::Group->flatten_group_membership($group->id);
2141
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}++;
2146     }
2147     
2148     my $cc_table = "cc_$chart_id";
2149     push(@$joins, { table => 'cc', as => $cc_table });
2150     my $group_table = "user_group_map_$chart_id";
2151     my $group_join = {
2152         table => 'user_group_map',
2153         as    => $group_table,
2154         from  => "$cc_table.who",
2155         to    => 'user_id',
2156         extra => [$dbh->sql_in("$group_table.group_id", $all_groups),
2157                   "$group_table.isbless = 0"],
2158     };
2159     push(@$joins, $group_join);
2160
2161     if ($operator =~ /^not/) {
2162         $args->{term} = "$group_table.group_id IS NULL";
2163     }
2164     else {
2165         $args->{term} = "$group_table.group_id IS NOT NULL";
2166     }
2167 }
2168
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;
2174
2175     if ($value =~ /^(%\w+%)$/) {
2176         $args->{value} = pronoun($1, $user);
2177         $args->{quoted} = $args->{value};
2178         $args->{value_is_id} = 1;
2179     }
2180 }
2181
2182 #####################################################################
2183 # Search Functions
2184 #####################################################################
2185
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 });
2191 }
2192
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)};
2199
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".
2207         $as =~ s/\./_/g;        
2208         # This helps implement the email1, email2, etc. parameters.
2209         if ($chart_id =~ /default/) {
2210             $as .= "_$sequence";
2211         }
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";
2219     }
2220
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;
2227     }
2228     else {
2229         my $as = "name_${field}_$chart_id";
2230         # For fields with periods in their name.
2231         $as =~ s/\./_/;
2232         my $join = {
2233             table => 'profiles',
2234             as    => $as,
2235             from  => $args->{full_field},
2236             to    => 'userid',
2237             join  => (!$is_in_other_table and !$is_nullable) ? 'INNER' : undef,
2238         };
2239         push(@$joins, $join);
2240         $args->{full_field} = "$as.login_name";
2241     }
2242     
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,
2246     # it does.
2247     if ($is_nullable) {
2248         $args->{full_field} = "COALESCE($args->{full_field}, $null_alternate)";
2249     }
2250     
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
2253     # of the WHERE.
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];
2258         
2259         # For negative operators, the system we're using here
2260         # only works properly if we reverse the operator and check IS NULL
2261         # in the WHERE.
2262         my $is_negative = $operator =~ /^no/ ? 1 : 0;
2263         if ($is_negative) {
2264             $args->{operator} = $self->_reverse_operator($operator);
2265         }
2266         $self->_do_operator_function($args);
2267         push(@{ $last_join->{extra} }, $args->{term});
2268         
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
2272         # tables.
2273         if ($last_join->{table} eq 'profiles') {
2274             pop @$joins;
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;
2291         }
2292         
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';
2296         if ($is_negative) {
2297             $args->{term} = "$check_field IS NULL";
2298         }
2299         else {
2300             $args->{term} = "$check_field IS NOT NULL";
2301         }
2302     }
2303 }
2304
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)};
2309     
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";
2314 }
2315
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;
2321     
2322     my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2323     my $table = "longdescs_$chart_id";
2324     my $sql_date = $dbh->quote(SqlifyDate($value));
2325     my $join = {
2326         table => 'longdescs',
2327         as    => $table,
2328         extra => ["$table.bug_when $sql_operator $sql_date"],
2329     };
2330     push(@$joins, $join);
2331     $args->{term} = "$table.bug_when IS NOT NULL";
2332
2333     # If the user is not part of the insiders group, they cannot see
2334     # private comments
2335     if (!$self->_user->is_insider) {
2336         $args->{term} .= " AND $table.isprivate = 0";
2337     }
2338 }
2339
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;
2345
2346     my $table = "longdescs_$chart_id";
2347     my $join_args = {
2348         chart_id   => $chart_id,
2349         sequence   => $chart_id,
2350         field      => 'longdesc',
2351         full_field => "$table.thetext",
2352         operator   => $operator,
2353         value      => $value,
2354         all_values => $value,
2355         quoted     => $dbh->quote($value),
2356         joins      => [],
2357     };
2358     $self->_do_operator_function($join_args);
2359
2360     # If the user is not part of the insiders group, they cannot see
2361     # private comments
2362     if (!$self->_user->is_insider) {
2363         $join_args->{term} .= " AND $table.isprivate = 0";
2364     }
2365
2366     my $join = {
2367         table => 'longdescs',
2368         as    => $table,
2369         extra => [ $join_args->{term} ],
2370     };
2371     push(@$joins, $join);
2372
2373     $args->{term} =  "$table.comment_id IS NOT NULL";
2374 }
2375
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;
2381
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
2387     # index searches.
2388
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 });
2394     
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;
2402
2403     # The term to use in the WHERE clause.
2404     my $term = "$term1 OR $term2";
2405     if ($operator =~ /not/i) {
2406         $term = "NOT($term)";
2407     }
2408     $args->{term} = $term;
2409     
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.
2414     #
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;
2422 }
2423
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";
2429     my $join = {
2430         table => "(SELECT bug_id, COUNT(*) AS num"
2431                  . " FROM longdescs $extra GROUP BY bug_id)",
2432         as    => $table,
2433     };
2434     push(@$joins, $join);
2435     $args->{full_field} = "${table}.num";
2436 }
2437
2438 sub _work_time_changedby {
2439     my ($self, $args) = @_;
2440     my ($chart_id, $joins, $value) = @$args{qw(chart_id joins value)};
2441     
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";
2446 }
2447
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;
2453     
2454     my $table = "longdescs_$chart_id";
2455     my $sql_operator = ($operator =~ /before/) ? '<=' : '>=';
2456     my $sql_date = $dbh->quote(SqlifyDate($value));
2457     my $join = {
2458         table => 'longdescs',
2459         as    => $table,
2460         extra => ["$table.work_time != 0",
2461                   "$table.bug_when $sql_operator $sql_date"],
2462     };
2463     push(@$joins, $join);
2464     
2465     $args->{term} = "$table.bug_when IS NOT NULL";
2466 }
2467
2468 sub _work_time {
2469     my ($self, $args) = @_;
2470     $self->_add_extra_column('actual_time');
2471     $args->{full_field} = $self->COLUMNS->{actual_time}->{name};
2472 }
2473
2474 sub _percentage_complete {
2475     my ($self, $args) = @_;
2476     
2477     $args->{full_field} = $self->COLUMNS->{percentage_complete}->{name};
2478
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');
2482 }
2483
2484 sub _days_elapsed {
2485     my ($self, $args) = @_;
2486     my $dbh = Bugzilla->dbh;
2487     
2488     $args->{full_field} = "(" . $dbh->sql_to_days('NOW()') . " - " .
2489                                 $dbh->sql_to_days('bugs.delta_ts') . ")";
2490 }
2491
2492 sub _component_nonchanged {
2493     my ($self, $args) = @_;
2494     
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});
2500 }
2501
2502 sub _product_nonchanged {
2503     my ($self, $args) = @_;
2504     
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);
2511 }
2512
2513 sub _classification_nonchanged {
2514     my ($self, $args) = @_;
2515     my $joins = $args->{joins};
2516     
2517     # This joins the right tables for us.
2518     $self->_add_extra_column('product');
2519     
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);
2526 }
2527
2528 sub _nullable {
2529     my ($self, $args) = @_;
2530     my $field = $args->{full_field};
2531     $args->{full_field} = "COALESCE($field, '')";
2532 }
2533
2534 sub _nullable_int {
2535     my ($self, $args) = @_;
2536     my $field = $args->{full_field};
2537     $args->{full_field} = "COALESCE($field, 0)";
2538 }
2539
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)";
2545 }
2546
2547 sub _deadline {
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);
2554 }
2555
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;
2561     
2562     my $table = "idle_$chart_id";
2563     my $quoted = $dbh->quote(SqlifyDate($value));
2564     
2565     my $ld_table = "comment_$table";
2566     my $act_table = "activity_$table";    
2567     my $comments_join = {
2568         table => 'longdescs',
2569         as    => $ld_table,
2570         from  => 'assigned_to',
2571         to    => 'who',
2572         extra => ["$ld_table.bug_when > $quoted"],
2573     };
2574     my $activity_join = {
2575         table => 'bugs_activity',
2576         as    => $act_table,
2577         from  => 'assigned_to',
2578         to    => 'who',
2579         extra => ["$act_table.bug_when > $quoted"]
2580     };
2581     
2582     push(@$joins, $comments_join, $activity_join);
2583     
2584     if ($operator =~ /greater/) {
2585         $args->{term} =
2586             "$ld_table.who IS NULL AND $act_table.who IS NULL";
2587     } else {
2588          $args->{term} =
2589             "$ld_table.who IS NOT NULL OR $act_table.who IS NOT NULL";
2590     }
2591 }
2592
2593 sub _multiselect_negative {
2594     my ($self, $args) = @_;
2595     my ($field, $operator) = @$args{qw(field operator)};
2596
2597     $args->{operator} = $self->_reverse_operator($operator);
2598     $args->{term} = $self->_multiselect_term($args, 1);
2599 }
2600
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;
2606     
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);
2613     
2614     my @terms;
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));
2620     }
2621     
2622     # The spacing in the joins helps make the resulting SQL more readable.
2623     if ($operator =~ /^any/) {
2624         $args->{term} = join("\n        OR ", @terms);
2625     }
2626     else {
2627         $args->{term} = join("\n        AND ", @terms);
2628     }
2629 }
2630
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;
2636
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);
2645     }
2646
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,
2653         value      => $value,
2654         all_values => $value,
2655         quoted     => $dbh->quote($value),
2656         joins      => [],
2657     };
2658     $self->_do_operator_function($subselect_args);
2659     my $subselect_term = $subselect_args->{term};
2660
2661     # don't call build_subselect as this must run as a true sub-select
2662     $args->{term} = "EXISTS (
2663         SELECT 1
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
2674            AND $subselect_term
2675     )";
2676 }
2677
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)
2683 }
2684
2685 sub _multiselect_table {
2686     my ($self, $args) = @_;
2687     my ($field, $chart_id) = @$args{qw(field chart_id)};
2688     my $dbh = Bugzilla->dbh;
2689     
2690     if ($field eq 'keywords') {
2691         $args->{full_field} = 'keyworddefs.name';
2692         return "keywords INNER JOIN keyworddefs".
2693                                " ON keywords.keywordid = keyworddefs.id";
2694     }
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);
2699     }
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";
2704     }
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";
2710     }
2711     elsif ($field eq 'longdesc') {
2712         $args->{_extra_where} = " AND isprivate = 0"
2713             if !$self->_user->is_insider;
2714         $args->{full_field} = 'thetext';
2715         return "longdescs";
2716     }
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';
2723         return "longdescs";
2724     }
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";
2731     }
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"
2737     }
2738     elsif ($field eq 'flagtypes.name') {
2739         $args->{full_field} = $dbh->sql_string_concat("flagtypes.name",
2740                                                       "flags.status");
2741         return "flags INNER JOIN flagtypes ON flags.type_id = flagtypes.id";
2742     }
2743     my $table = "bug_$field";
2744     $args->{full_field} = "bug_$field.value";
2745     return $table;
2746 }
2747
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);
2756 }
2757
2758 ###############################
2759 # Standard Operator Functions #
2760 ###############################
2761
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";
2768 }
2769
2770 sub _casesubstring {
2771     my ($self, $args) = @_;
2772     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2773     my $dbh = Bugzilla->dbh;
2774     
2775     $args->{term} = $dbh->sql_position($quoted, $full_field) . " > 0";
2776 }
2777
2778 sub _substring {
2779     my ($self, $args) = @_;
2780     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2781     my $dbh = Bugzilla->dbh;
2782     
2783     # XXX This should probably be changed to just use LIKE
2784     $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " > 0";
2785 }
2786
2787 sub _notsubstring {
2788     my ($self, $args) = @_;
2789     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2790     my $dbh = Bugzilla->dbh;
2791     
2792     # XXX This should probably be changed to just use NOT LIKE
2793     $args->{term} = $dbh->sql_iposition($quoted, $full_field) . " = 0";
2794 }
2795
2796 sub _regexp {
2797     my ($self, $args) = @_;
2798     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2799     my $dbh = Bugzilla->dbh;
2800     
2801     $args->{term} = $dbh->sql_regexp($full_field, $quoted);
2802 }
2803
2804 sub _notregexp {
2805     my ($self, $args) = @_;
2806     my ($full_field, $quoted) = @$args{qw(full_field quoted)};
2807     my $dbh = Bugzilla->dbh;
2808     
2809     $args->{term} = $dbh->sql_not_regexp($full_field, $quoted);
2810 }
2811
2812 sub _anyexact {
2813     my ($self, $args) = @_;
2814     my ($field, $full_field) = @$args{qw(field full_field)};
2815     my $dbh = Bugzilla->dbh;
2816     
2817     my @list = $self->_all_values($args, ',');
2818     @list = map { $self->_quote_unless_numeric($args, $_) } @list;
2819     
2820     if (@list) {
2821         $args->{term} = $dbh->sql_in($full_field, \@list);
2822     }
2823     else {
2824         $args->{term} = '';
2825     }
2826 }
2827
2828 sub _anywordsubstr {
2829     my ($self, $args) = @_;
2830
2831     my @terms = $self->_substring_terms($args);
2832     $args->{term} = join("\n\tOR ", @terms);
2833 }
2834
2835 sub _allwordssubstr {
2836     my ($self, $args) = @_;
2837
2838     my @terms = $self->_substring_terms($args);
2839     $args->{term} = join("\n\tAND ", @terms);
2840 }
2841
2842 sub _nowordssubstr {
2843     my ($self, $args) = @_;
2844     $self->_anywordsubstr($args);
2845     my $term = $args->{term};
2846     $args->{term} = "NOT($term)";
2847 }
2848
2849 sub _anywords {
2850     my ($self, $args) = @_;
2851     
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);
2857 }
2858
2859 sub _allwords {
2860     my ($self, $args) = @_;
2861     
2862     my @terms = $self->_word_terms($args);
2863     $args->{term} = join("\n\tAND ", @terms);
2864 }
2865
2866 sub _nowords {
2867     my ($self, $args) = @_;
2868     $self->_anywords($args);
2869     my $term = $args->{term};
2870     $args->{term} = "NOT($term)";
2871 }
2872
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;
2878
2879     my $field_object = $self->_chart_fields->{$field}
2880         || ThrowCodeError("invalid_field_name", { field => $field });
2881     
2882     # Asking when creation_ts changed is just asking when the bug was created.
2883     if ($field_object->name eq 'creation_ts') {
2884         $args->{operator} =
2885             $operator eq 'changedbefore' ? 'lessthaneq' : 'greaterthaneq';
2886         return $self->_do_operator_function($args);
2887     }
2888     
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";
2894
2895     my $sql_date = $dbh->quote(SqlifyDate($value));
2896     my $join = {
2897         table => 'bugs_activity',
2898         as    => $table,
2899         extra => ["$table.fieldid = $field_id",
2900                   "$table.bug_when $sql_operator $sql_date"],
2901     };
2902
2903     $args->{term} = "$table.bug_when IS NOT NULL";
2904     $self->_changed_security_check($args, $join);
2905     push(@$joins, $join);
2906 }
2907
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)};
2912     
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";
2918     my $join = {
2919         table => 'bugs_activity',
2920         as    => $table,
2921         extra => ["$table.fieldid = $field_id",
2922                   "$table.$column = $quoted"],
2923     };
2924
2925     $args->{term} = "$table.bug_when IS NOT NULL";
2926     $self->_changed_security_check($args, $join);
2927     push(@$joins, $join);
2928 }
2929
2930 sub _changedby {
2931     my ($self, $args) = @_;
2932     my ($chart_id, $joins, $field, $operator, $value) =
2933         @$args{qw(chart_id joins field operator value)};
2934     
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);
2940     my $join = {
2941         table => 'bugs_activity',
2942         as    => $table,
2943         extra => ["$table.fieldid = $field_id",
2944                   "$table.who = $user_id"],
2945     };
2946
2947     $args->{term} = "$table.bug_when IS NOT NULL";
2948     $self->_changed_security_check($args, $join);
2949     push(@$joins, $join);
2950 }
2951
2952 sub _changed_security_check {
2953     my ($self, $args, $join) = @_;
2954     my ($chart_id, $field) = @$args{qw(chart_id field)};
2955
2956     my $field_object = $self->_chart_fields->{$field}
2957         || ThrowCodeError("invalid_field_name", { field => $field });
2958     my $field_id = $field_object->id;
2959
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",
2967             to    => 'attach_id',
2968         };
2969
2970         $args->{term} .= " AND COALESCE(attach_${field_id}_$chart_id.isprivate, 0) = 0";
2971     }
2972 }
2973
2974 ######################
2975 # Public Subroutines #
2976 ######################
2977
2978 # Validate that the query type is one we can deal with
2979 sub IsValidQueryType
2980 {
2981     my ($queryType) = @_;
2982     if (grep { $_ eq $queryType } qw(specific advanced)) {
2983         return 1;
2984     }
2985     return 0;
2986 }
2987
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;
2994 }
2995
2996 # Used to translate old SQL fragments from buglist.cgi's "order" argument
2997 # into our modern field IDs.
2998 sub translate_old_column {
2999     my ($column) = @_;
3000     # All old SQL fragments have a period in them somewhere.
3001     return $column if $column !~ /\./;
3002
3003     if ($column =~ /\bAS\s+(\w+)$/i) {
3004         return $1;
3005     }
3006     # product, component, classification, assigned_to, qa_contact, reporter
3007     elsif ($column =~ /map_(\w+?)s?\.(login_)?name/i) {
3008         return $1;
3009     }
3010     
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;
3016     }
3017
3018     return $column;
3019 }
3020
3021 1;